Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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
Expand All @@ -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());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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
Expand All @@ -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*/) {
Expand Down
9 changes: 9 additions & 0 deletions src/main/java/com/matzip/auth/service/AuthService.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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)
Expand Down
30 changes: 30 additions & 0 deletions src/main/java/com/matzip/common/analytics/AnalyticsEvent.java
Original file line number Diff line number Diff line change
@@ -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<String, Object> 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());
}
}
Original file line number Diff line number Diff line change
@@ -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);
}
}
}
32 changes: 32 additions & 0 deletions src/main/java/com/matzip/common/analytics/AnalyticsRecorder.java
Original file line number Diff line number Diff line change
@@ -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));
}
}
44 changes: 44 additions & 0 deletions src/main/java/com/matzip/common/analytics/JsonMapConverter.java
Original file line number Diff line number Diff line change
@@ -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<Map<String, Object>, String> {

private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
private static final TypeReference<Map<String, Object>> MAP_TYPE = new TypeReference<>() {
};

@Override
public String convertToDatabaseColumn(Map<String, Object> 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<String, Object> 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);
}
}
}
14 changes: 14 additions & 0 deletions src/main/java/com/matzip/common/analytics/domain/EventType.java
Original file line number Diff line number Diff line change
@@ -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
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package com.matzip.common.analytics.domain;

/**
* 이벤트가 가리키는 대상 종류. target_id와 함께 저장해 비즈니스 테이블과 조인할 때 쓴다.
*/
public enum TargetType {
PLACE,
LOTTERY_EVENT
}
Original file line number Diff line number Diff line change
@@ -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<String, Object> 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<String, Object> 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();
}
}
Original file line number Diff line number Diff line change
@@ -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<EventLog, Long> {
}
10 changes: 10 additions & 0 deletions src/main/java/com/matzip/place/service/PlaceService.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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<Long> categoryIds = req.getCategoryIds();
if (categoryIds.size() != new LinkedHashSet<>(categoryIds).size()) {
throw new IllegalArgumentException("categoryId는 중복될 수 없습니다.");
Expand Down
Loading
Loading