properties() {
+ return properties;
+ }
+
+ boolean userIdOverridden() {
+ return userIdOverridden;
+ }
+
+ Long userId() {
+ return userId;
+ }
+
+ boolean anonymousIdOverridden() {
+ return anonymousIdOverridden;
+ }
+
+ String anonymousId() {
+ return anonymousId;
+ }
+}
diff --git a/src/main/java/com/ject/vs/analytics/AnalyticsEventLogger.java b/src/main/java/com/ject/vs/analytics/AnalyticsEventLogger.java
new file mode 100644
index 00000000..a86bee4c
--- /dev/null
+++ b/src/main/java/com/ject/vs/analytics/AnalyticsEventLogger.java
@@ -0,0 +1,110 @@
+package com.ject.vs.analytics;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import jakarta.servlet.http.Cookie;
+import jakarta.servlet.http.HttpServletRequest;
+import lombok.RequiredArgsConstructor;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.core.context.SecurityContextHolder;
+import org.springframework.stereotype.Component;
+import org.springframework.web.context.request.RequestContextHolder;
+import org.springframework.web.context.request.ServletRequestAttributes;
+
+import java.time.Clock;
+import java.time.Instant;
+import java.util.Arrays;
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+/**
+ * 행동 로그(analytics event)를 구조화된 JSON 한 줄로 남기는 컴포넌트.
+ *
+ * 공통 로그 변수(user_id / anonymous_id / is_member / platform / occurred_at)는
+ * 현재 HTTP 요청 컨텍스트에서 자동으로 채워진다. 이벤트별 변수는 {@link AnalyticsEvent}로 전달한다.
+ *
+ *
{@code
+ * analytics.log(AnalyticsEvent.of("vote_detail_viewed")
+ * .put("vote_id", voteId)
+ * .put("vote_status", status));
+ * }
+ *
+ * 유일한 진입점이 void {@code log(AnalyticsEvent)}이므로 테스트에서 목(mock)으로 주입해도
+ * 부수효과 없이 동작한다. 로그 적재 실패가 비즈니스 로직에 영향을 주지 않도록 모든 예외를 삼킨다.
+ */
+@Component
+@RequiredArgsConstructor
+public class AnalyticsEventLogger {
+
+ /** 별도 로거 이름으로 분리하여 수집/필터링이 쉽도록 한다. */
+ private static final Logger log = LoggerFactory.getLogger("analytics");
+
+ private static final String ANONYMOUS_COOKIE = "anonymous_id";
+
+ private final ObjectMapper objectMapper;
+ private final Clock clock;
+
+ public void log(AnalyticsEvent event) {
+ try {
+ HttpServletRequest request = currentRequest();
+
+ Long userId = event.userIdOverridden() ? event.userId() : resolveUserId();
+ String anonymousId = event.anonymousIdOverridden()
+ ? event.anonymousId()
+ : resolveAnonymousId(request);
+
+ Map payload = new LinkedHashMap<>();
+ payload.put("event", event.name());
+ payload.put("user_id", userId);
+ payload.put("anonymous_id", anonymousId);
+ payload.put("is_member", userId != null);
+ payload.put("platform", resolvePlatform(request));
+ payload.put("occurred_at", Instant.now(clock).toString());
+ payload.putAll(event.properties());
+
+ AnalyticsEventLogger.log.info(objectMapper.writeValueAsString(payload));
+ } catch (Exception e) {
+ // 로깅 실패가 요청 처리에 영향을 주지 않도록 흡수
+ AnalyticsEventLogger.log.warn("analytics event logging failed for '{}': {}", event.name(), e.getMessage());
+ }
+ }
+
+ private HttpServletRequest currentRequest() {
+ var attributes = RequestContextHolder.getRequestAttributes();
+ if (attributes instanceof ServletRequestAttributes servletAttributes) {
+ return servletAttributes.getRequest();
+ }
+ return null;
+ }
+
+ private Long resolveUserId() {
+ Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
+ if (authentication == null) return null;
+ Object principal = authentication.getPrincipal();
+ return (principal instanceof Long userId) ? userId : null;
+ }
+
+ private String resolveAnonymousId(HttpServletRequest request) {
+ if (request == null || request.getCookies() == null) return null;
+ return Arrays.stream(request.getCookies())
+ .filter(c -> ANONYMOUS_COOKIE.equals(c.getName()))
+ .map(Cookie::getValue)
+ .findFirst()
+ .orElse(null);
+ }
+
+ /** User-Agent 기반 플랫폼 분류: ios / android / web. */
+ private String resolvePlatform(HttpServletRequest request) {
+ if (request == null) return "unknown";
+ String ua = request.getHeader("User-Agent");
+ if (ua == null || ua.isBlank()) return "unknown";
+ String lower = ua.toLowerCase();
+ if (lower.contains("android")) return "android";
+ if (lower.contains("iphone") || lower.contains("ipad") || lower.contains("ios")
+ || lower.contains("cfnetwork") || lower.contains("darwin")) {
+ return "ios";
+ }
+ return "web";
+ }
+}
diff --git a/src/main/java/com/ject/vs/analytics/TrackingController.java b/src/main/java/com/ject/vs/analytics/TrackingController.java
new file mode 100644
index 00000000..5cc50d6a
--- /dev/null
+++ b/src/main/java/com/ject/vs/analytics/TrackingController.java
@@ -0,0 +1,40 @@
+package com.ject.vs.analytics;
+
+import com.ject.vs.config.UtmCookie;
+import com.ject.vs.user.domain.UtmAttribution;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import lombok.RequiredArgsConstructor;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+/**
+ * 유입 추적 엔드포인트.
+ *
+ * UTM 파라미터는 프론트 SPA URL(예: {@code /vote/123?utm_source=instagram})에 붙어 들어오므로
+ * 백엔드는 직접 볼 수 없다. 프론트가 랜딩 시 이 엔드포인트를 1회 호출해 UTM을 넘겨주면,
+ * 백엔드는 first-touch 출처를 쿠키에 박제하고 이후 가입 완료 시 users 테이블에 기록한다.
+ */
+@RestController
+@RequestMapping("/api/track")
+@RequiredArgsConstructor
+public class TrackingController {
+
+ private final UtmCookie utmCookie;
+
+ @GetMapping("/visit")
+ public ResponseEntity visit(
+ @RequestParam(value = "utm_source", required = false) String source,
+ @RequestParam(value = "utm_medium", required = false) String medium,
+ @RequestParam(value = "utm_campaign", required = false) String campaign,
+ @RequestParam(value = "utm_content", required = false) String content,
+ HttpServletRequest request,
+ HttpServletResponse response) {
+
+ utmCookie.writeFirstTouch(request, response, UtmAttribution.of(source, medium, campaign, content));
+ return ResponseEntity.noContent().build();
+ }
+}
diff --git a/src/main/java/com/ject/vs/auth/port/AuthService.java b/src/main/java/com/ject/vs/auth/port/AuthService.java
index 0d263d5d..d8d40cc0 100644
--- a/src/main/java/com/ject/vs/auth/port/AuthService.java
+++ b/src/main/java/com/ject/vs/auth/port/AuthService.java
@@ -11,6 +11,7 @@
import com.ject.vs.common.exception.BusinessException;
import com.ject.vs.user.domain.User;
import com.ject.vs.user.domain.UserRepository;
+import com.ject.vs.user.domain.UtmAttribution;
import com.ject.vs.user.exception.UserErrorCode;
import com.ject.vs.user.port.UserService;
import com.ject.vs.util.JwtProvider;
@@ -32,7 +33,11 @@ public class AuthService {
private final JwtProvider jwtProvider;
public LoginTokenResponse socialLogin(String email) {
- User user = userService.findOrCreate(email);
+ return socialLogin(email, UtmAttribution.empty());
+ }
+
+ public LoginTokenResponse socialLogin(String email, UtmAttribution utm) {
+ User user = userService.findOrCreate(email, utm);
TokenInfo accessTokenInfo = jwtProvider.createAccessToken(user.getId());
TokenInfo refreshTokenInfo = jwtProvider.createRefreshToken(user.getId());
diff --git a/src/main/java/com/ject/vs/chat/adapter/event/ChatMessageEventListener.java b/src/main/java/com/ject/vs/chat/adapter/event/ChatMessageEventListener.java
index b1fb6902..f1e6551c 100644
--- a/src/main/java/com/ject/vs/chat/adapter/event/ChatMessageEventListener.java
+++ b/src/main/java/com/ject/vs/chat/adapter/event/ChatMessageEventListener.java
@@ -45,6 +45,7 @@ public void handle(ChatMessageSentEvent event) {
sender.getNickname(),
sender.getImageColor(),
voteOptionCode,
+ false,
false
);
diff --git a/src/main/java/com/ject/vs/chat/adapter/web/ChatController.java b/src/main/java/com/ject/vs/chat/adapter/web/ChatController.java
index bc89169b..6544e2c3 100644
--- a/src/main/java/com/ject/vs/chat/adapter/web/ChatController.java
+++ b/src/main/java/com/ject/vs/chat/adapter/web/ChatController.java
@@ -1,5 +1,7 @@
package com.ject.vs.chat.adapter.web;
+import com.ject.vs.analytics.AnalyticsEvent;
+import com.ject.vs.analytics.AnalyticsEventLogger;
import com.ject.vs.chat.adapter.web.dto.*;
import com.ject.vs.chat.port.in.*;
import com.ject.vs.chat.port.in.dto.*;
@@ -10,6 +12,8 @@
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
+import java.util.List;
+
@RestController
@RequestMapping("/api/chats")
@RequiredArgsConstructor
@@ -17,23 +21,42 @@ public class ChatController implements ChatDocs {
private final ChatQueryUseCase chatQueryUseCase;
private final ChatCommandUseCase chatCommandUseCase;
+ private final AnalyticsEventLogger analytics;
@GetMapping
@Override
public ChatListResponse getChatList(@AuthenticationPrincipal Long userId,
@RequestParam VoteStatus status) {
- return new ChatListResponse(
- chatQueryUseCase.getChatList(userId, status).stream()
- .map(ChatListItemResponse::from)
- .toList()
- );
+ List chats = chatQueryUseCase.getChatList(userId, status).stream()
+ .map(ChatListItemResponse::from)
+ .toList();
+
+ // unread_total_count: 각 채팅방의 안 읽은 메시지 수를 서버에서 합산(SUM)
+ long unreadTotalCount = chats.stream()
+ .mapToLong(ChatListItemResponse::unreadCount)
+ .sum();
+
+ analytics.log(AnalyticsEvent.of("chat_list_viewed")
+ .put("status", status)
+ .put("chat_count", chats.size())
+ .put("unread_total_count", unreadTotalCount));
+
+ return new ChatListResponse(chats);
}
@GetMapping("/{voteId}")
@Override
public ChatRoomResponse getChatRoom(@AuthenticationPrincipal Long userId,
@PathVariable Long voteId) {
- return ChatRoomResponse.from(chatQueryUseCase.getChatRoom(voteId));
+ ChatRoomResponse response = ChatRoomResponse.from(chatQueryUseCase.getChatRoom(voteId));
+
+ analytics.log(AnalyticsEvent.of("chat_room_entered")
+ .put("vote_id", response.voteId())
+ .put("vote_status", response.status())
+ .put("participant_count", response.participantCount())
+ .put("end_at", response.endAt()));
+
+ return response;
}
@GetMapping("/{voteId}/gauge")
@@ -48,7 +71,15 @@ public MessagePageResponse getMessages(@PathVariable Long voteId,
@AuthenticationPrincipal Long userId,
@RequestParam(required = false) Long cursor,
@RequestParam(defaultValue = "30") int size) {
- return MessagePageResponse.from(chatQueryUseCase.getMessages(voteId, userId, cursor, size));
+ MessagePageResponse response = MessagePageResponse.from(chatQueryUseCase.getMessages(voteId, userId, cursor, size));
+
+ analytics.log(AnalyticsEvent.of("chat_messages_viewed")
+ .put("vote_id", voteId)
+ .put("message_count", response.messages().size())
+ .put("next_cursor", response.nextCursor())
+ .put("has_next", response.hasNext()));
+
+ return response;
}
@PostMapping("/{voteId}/messages")
@@ -58,7 +89,17 @@ public MessageResponse sendMessage(@PathVariable Long voteId,
@AuthenticationPrincipal Long userId,
@RequestBody @Valid SendMessageRequest request) {
SendMessageCommand command = new SendMessageCommand(voteId, userId, request.content());
- return MessageResponse.from(chatCommandUseCase.sendMessage(command));
+ MessageResult result = chatCommandUseCase.sendMessage(command);
+
+ analytics.log(AnalyticsEvent.of("chat_message_sent")
+ .put("vote_id", voteId)
+ .put("message_id", result.messageId())
+ .put("message_length", request.content() != null ? request.content().length() : 0)
+ .put("sender_vote_option", result.senderVoteOption())
+ .put("is_mine", result.isMine())
+ .put("is_first_message", result.isFirstMessage()));
+
+ return MessageResponse.from(result);
}
@PostMapping("/{voteId}/read")
@@ -68,5 +109,9 @@ public void markAsRead(@PathVariable Long voteId,
@AuthenticationPrincipal Long userId,
@RequestBody @Valid MarkAsReadRequest request) {
chatCommandUseCase.markAsRead(new MarkAsReadCommand(voteId, userId, request.lastReadMessageId()));
+
+ analytics.log(AnalyticsEvent.of("chat_read_updated")
+ .put("vote_id", voteId)
+ .put("last_read_message_id", request.lastReadMessageId()));
}
}
diff --git a/src/main/java/com/ject/vs/chat/port/ChatService.java b/src/main/java/com/ject/vs/chat/port/ChatService.java
index d031be35..25005e5c 100644
--- a/src/main/java/com/ject/vs/chat/port/ChatService.java
+++ b/src/main/java/com/ject/vs/chat/port/ChatService.java
@@ -47,6 +47,9 @@ public MessageResult sendMessage(SendMessageCommand command) {
throw new InvalidMessageException();
}
+ // 행동 로그(is_first_message)용: 저장 전 기존 메시지 존재 여부로 첫 메시지인지 판단
+ boolean isFirstMessage = chatMessageRepository.countByVoteId(command.voteId()) == 0;
+
ChatMessage saved = chatMessageRepository.save(message);
User sender = userQueryUseCase.getUser(command.senderId());
VoteOptionCode voteOptionCode =
@@ -59,7 +62,8 @@ public MessageResult sendMessage(SendMessageCommand command) {
sender.getNickname(),
sender.getImageColor(),
voteOptionCode,
- true
+ true,
+ isFirstMessage
);
}
@@ -150,7 +154,8 @@ public MessagePageResult getMessages(Long voteId, Long userId, Long cursor, int
sender.getNickname(),
sender.getImageColor(),
voteOptionCode,
- msg.getSenderId().equals(userId)
+ msg.getSenderId().equals(userId),
+ false
);
})
.toList();
diff --git a/src/main/java/com/ject/vs/chat/port/in/dto/MessageResult.java b/src/main/java/com/ject/vs/chat/port/in/dto/MessageResult.java
index 3de948b6..23d31360 100644
--- a/src/main/java/com/ject/vs/chat/port/in/dto/MessageResult.java
+++ b/src/main/java/com/ject/vs/chat/port/in/dto/MessageResult.java
@@ -12,5 +12,8 @@ public record MessageResult(
String senderNickname,
ImageColor senderProfileIcon,
VoteOptionCode senderVoteOption,
- boolean isMine
+ boolean isMine,
+ // 행동 로그(chat_message_sent)의 is_first_message 변수: 해당 채팅방의 첫 메시지인지 여부.
+ // 메시지 전송 시점에만 의미를 가지며, 목록 조회/실시간 브로드캐스트에서는 false로 채운다.
+ boolean isFirstMessage
) {}
diff --git a/src/main/java/com/ject/vs/common/exception/GlobalExceptionHandler.java b/src/main/java/com/ject/vs/common/exception/GlobalExceptionHandler.java
index fbc38b09..0f287407 100644
--- a/src/main/java/com/ject/vs/common/exception/GlobalExceptionHandler.java
+++ b/src/main/java/com/ject/vs/common/exception/GlobalExceptionHandler.java
@@ -1,5 +1,10 @@
package com.ject.vs.common.exception;
+import com.ject.vs.analytics.AnalyticsEvent;
+import com.ject.vs.analytics.AnalyticsEventLogger;
+import com.ject.vs.vote.exception.VoteFreeLimitExceededException;
+import jakarta.servlet.http.HttpServletRequest;
+import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.FieldError;
import org.springframework.web.bind.MethodArgumentNotValidException;
@@ -7,8 +12,44 @@
import org.springframework.web.bind.annotation.RestControllerAdvice;
@RestControllerAdvice
+@RequiredArgsConstructor
public class GlobalExceptionHandler {
+ private final AnalyticsEventLogger analytics;
+
+ /**
+ * 무료 투표 한도 초과. 행동 로그(free_limit_exceeded)를 남긴 뒤 공통 비즈니스 예외 처리로 위임한다.
+ */
+ @ExceptionHandler(VoteFreeLimitExceededException.class)
+ public ResponseEntity handleFreeLimitExceeded(VoteFreeLimitExceededException e,
+ HttpServletRequest request) {
+ ErrorCode errorCode = e.getErrorCode();
+ analytics.log(AnalyticsEvent.of("free_limit_exceeded")
+ .put("vote_id", extractVoteId(request))
+ .put("error_code", errorCode.getCode())
+ .put("remaining_free_votes", 0));
+ return ResponseEntity.status(errorCode.getStatusCode())
+ .body(new ErrorResponse(errorCode.getCode(), errorCode.getMessage()));
+ }
+
+ /** /api/votes/{voteId}/... 또는 /api/immersive-votes/{voteId}/... 경로에서 voteId 추출. */
+ private Long extractVoteId(HttpServletRequest request) {
+ if (request == null) return null;
+ String uri = request.getRequestURI();
+ if (uri == null) return null;
+ String[] parts = uri.split("/");
+ for (int i = 0; i < parts.length - 1; i++) {
+ if (parts[i].endsWith("votes")) {
+ try {
+ return Long.parseLong(parts[i + 1]);
+ } catch (NumberFormatException ignored) {
+ // 숫자가 아니면 다음 후보 탐색
+ }
+ }
+ }
+ return null;
+ }
+
@ExceptionHandler(BusinessException.class)
public ResponseEntity handleBusiness(BusinessException e) {
ErrorCode errorCode = e.getErrorCode();
diff --git a/src/main/java/com/ject/vs/config/OAuth2LoginSuccessHandler.java b/src/main/java/com/ject/vs/config/OAuth2LoginSuccessHandler.java
index 047d1b09..f76a958b 100644
--- a/src/main/java/com/ject/vs/config/OAuth2LoginSuccessHandler.java
+++ b/src/main/java/com/ject/vs/config/OAuth2LoginSuccessHandler.java
@@ -1,9 +1,12 @@
package com.ject.vs.config;
+import com.ject.vs.analytics.AnalyticsEvent;
+import com.ject.vs.analytics.AnalyticsEventLogger;
import com.ject.vs.auth.port.AuthService;
import com.ject.vs.auth.port.in.dto.LoginTokenResponse;
import com.ject.vs.common.exception.BusinessException;
import com.ject.vs.user.domain.UserStatus;
+import com.ject.vs.user.domain.UtmAttribution;
import com.ject.vs.util.CookieUtil;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
@@ -12,6 +15,7 @@
import org.springframework.http.HttpHeaders;
import org.springframework.http.ResponseCookie;
import org.springframework.security.core.Authentication;
+import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken;
import org.springframework.security.oauth2.core.user.OAuth2User;
import org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler;
import org.springframework.stereotype.Component;
@@ -27,6 +31,8 @@ public class OAuth2LoginSuccessHandler extends SimpleUrlAuthenticationSuccessHan
private final OAuth2Properties oauth2Properties;
private final JwtProperties jwtProperties;
private final CookieProperties cookieProperties;
+ private final AnalyticsEventLogger analytics;
+ private final UtmCookie utmCookie;
@Override
public void onAuthenticationSuccess(HttpServletRequest request,
@@ -37,12 +43,28 @@ public void onAuthenticationSuccess(HttpServletRequest request,
String email = oAuth2User.getAttribute("email");
try {
- LoginTokenResponse loginResponse = authService.socialLogin(email);
+ UtmAttribution utm = utmCookie.read(request);
+
+ LoginTokenResponse loginResponse = authService.socialLogin(email, utm);
addTokenCookies(response, loginResponse);
String targetUrl = determineTargetUrl(loginResponse.getUserStatus());
+ AnalyticsEvent event = AnalyticsEvent.of("signup_completed")
+ .userId(loginResponse.getUserId())
+ .put("method", resolveMethod(authentication));
+ if (!utm.isEmpty()) {
+ event.put("utm_source", utm.source())
+ .put("utm_medium", utm.medium())
+ .put("utm_campaign", utm.campaign())
+ .put("utm_content", utm.content());
+ }
+ analytics.log(event);
+
+ // 출처를 소비했으므로 쿠키를 만료시켜 다음 가입에 새 유입이 잡히도록 한다.
+ utmCookie.clear(response);
+
log.info("=== OAuth2 Login Success ===");
log.info("email: {}", email);
log.info("userStatus: {}", loginResponse.getUserStatus());
@@ -83,6 +105,14 @@ private void addTokenCookies(HttpServletResponse response, LoginTokenResponse lo
response.addHeader(HttpHeaders.SET_COOKIE, refreshTokenCookie.toString());
}
+ /** 소셜 로그인 제공자(kakao/apple)를 method 값으로 사용. */
+ private String resolveMethod(Authentication authentication) {
+ if (authentication instanceof OAuth2AuthenticationToken token) {
+ return token.getAuthorizedClientRegistrationId();
+ }
+ return null;
+ }
+
private String determineTargetUrl(UserStatus status) {
if (UserStatus.REGISTER.equals(status)) {
return oauth2Properties.redirectSuccessUrl();
diff --git a/src/main/java/com/ject/vs/config/SecurityPaths.java b/src/main/java/com/ject/vs/config/SecurityPaths.java
index 4763e17b..e122e81f 100644
--- a/src/main/java/com/ject/vs/config/SecurityPaths.java
+++ b/src/main/java/com/ject/vs/config/SecurityPaths.java
@@ -16,7 +16,8 @@ public class SecurityPaths {
"/ws/**",
"/oauth2/authorization/**",
"/login/oauth2/code/**",
- "/api/home/**"
+ "/api/home/**",
+ "/api/track/**"
);
/**
diff --git a/src/main/java/com/ject/vs/config/UtmCookie.java b/src/main/java/com/ject/vs/config/UtmCookie.java
new file mode 100644
index 00000000..3784f7d6
--- /dev/null
+++ b/src/main/java/com/ject/vs/config/UtmCookie.java
@@ -0,0 +1,110 @@
+package com.ject.vs.config;
+
+import com.ject.vs.user.domain.UtmAttribution;
+import jakarta.servlet.http.Cookie;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import lombok.RequiredArgsConstructor;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.ResponseCookie;
+import org.springframework.stereotype.Component;
+
+import java.net.URLDecoder;
+import java.net.URLEncoder;
+import java.nio.charset.StandardCharsets;
+import java.time.Duration;
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * 가입 유입 출처(UTM)를 비회원 브라우저에 박제하는 쿠키 헬퍼.
+ *
+ * 흐름: 프론트가 UTM 링크로 랜딩 → {@code /api/track/visit} 1회 호출 → 여기서 first-touch 쿠키 박제
+ * → 이후 소셜 로그인 가입이 완료되는 순간 {@link com.ject.vs.config.OAuth2LoginSuccessHandler} 가
+ * 쿠키를 읽어 users 테이블에 기록하고 쿠키를 만료시킨다.
+ *
+ *
값은 {@code s/m/c/ct} 키로 URL 인코딩해 한 쿠키에 담는다.
+ */
+@Component
+@RequiredArgsConstructor
+public class UtmCookie {
+
+ static final String COOKIE_NAME = "utm_attribution";
+ private static final Duration MAX_AGE = Duration.ofDays(30);
+
+ private final CookieProperties cookieProperties;
+
+ /** 최초 유입(first-touch)만 기록한다. 이미 쿠키가 있거나 UTM이 비어 있으면 아무것도 하지 않는다. */
+ public void writeFirstTouch(HttpServletRequest req, HttpServletResponse res, UtmAttribution utm) {
+ if (utm == null || utm.isEmpty()) {
+ return;
+ }
+ if (extractCookie(req) != null) {
+ return;
+ }
+ res.addHeader(HttpHeaders.SET_COOKIE, baseCookie(encode(utm), MAX_AGE).toString());
+ }
+
+ public UtmAttribution read(HttpServletRequest req) {
+ String raw = extractCookie(req);
+ return raw == null ? UtmAttribution.empty() : decode(raw);
+ }
+
+ /** 가입 시 출처를 소비한 뒤 쿠키를 만료시켜, 같은 브라우저의 다음 가입에 새 유입이 잡히도록 한다. */
+ public void clear(HttpServletResponse res) {
+ res.addHeader(HttpHeaders.SET_COOKIE, baseCookie("", Duration.ZERO).toString());
+ }
+
+ private ResponseCookie baseCookie(String value, Duration maxAge) {
+ return ResponseCookie.from(COOKIE_NAME, value)
+ .httpOnly(true)
+ .secure(cookieProperties.secure())
+ .sameSite(cookieProperties.sameSite())
+ .maxAge(maxAge)
+ .path("/")
+ .build();
+ }
+
+ private String encode(UtmAttribution utm) {
+ StringBuilder sb = new StringBuilder();
+ append(sb, "s", utm.source());
+ append(sb, "m", utm.medium());
+ append(sb, "c", utm.campaign());
+ append(sb, "ct", utm.content());
+ return sb.toString();
+ }
+
+ private void append(StringBuilder sb, String key, String value) {
+ if (value == null) {
+ return;
+ }
+ if (sb.length() > 0) {
+ sb.append('&');
+ }
+ sb.append(key).append('=').append(URLEncoder.encode(value, StandardCharsets.UTF_8));
+ }
+
+ private UtmAttribution decode(String raw) {
+ Map map = new HashMap<>();
+ for (String pair : raw.split("&")) {
+ int idx = pair.indexOf('=');
+ if (idx <= 0) {
+ continue;
+ }
+ map.put(pair.substring(0, idx), URLDecoder.decode(pair.substring(idx + 1), StandardCharsets.UTF_8));
+ }
+ return UtmAttribution.of(map.get("s"), map.get("m"), map.get("c"), map.get("ct"));
+ }
+
+ private String extractCookie(HttpServletRequest req) {
+ if (req == null || req.getCookies() == null) {
+ return null;
+ }
+ return Arrays.stream(req.getCookies())
+ .filter(c -> COOKIE_NAME.equals(c.getName()))
+ .map(Cookie::getValue)
+ .findFirst()
+ .orElse(null);
+ }
+}
diff --git a/src/main/java/com/ject/vs/notification/adapter/web/DeviceController.java b/src/main/java/com/ject/vs/notification/adapter/web/DeviceController.java
index ac4836a4..3c8775f8 100644
--- a/src/main/java/com/ject/vs/notification/adapter/web/DeviceController.java
+++ b/src/main/java/com/ject/vs/notification/adapter/web/DeviceController.java
@@ -1,5 +1,7 @@
package com.ject.vs.notification.adapter.web;
+import com.ject.vs.analytics.AnalyticsEvent;
+import com.ject.vs.analytics.AnalyticsEventLogger;
import com.ject.vs.notification.adapter.web.dto.RegisterPushTokenRequest;
import com.ject.vs.notification.port.in.PushTokenUseCase;
import com.ject.vs.vote.exception.UnauthorizedException;
@@ -18,6 +20,7 @@
public class DeviceController {
private final PushTokenUseCase useCase;
+ private final AnalyticsEventLogger analytics;
@Operation(summary = "푸시 토큰 등록", description = "FCM/APNs 디바이스 토큰을 등록합니다. 투표 종료 시 푸시 알림 발송에 사용됩니다.")
@PostMapping("/push-token")
@@ -27,6 +30,9 @@ public void register(
@RequestBody @Valid RegisterPushTokenRequest request) {
if (userId == null) throw new UnauthorizedException();
useCase.register(userId, request.token(), request.platform());
+
+ analytics.log(AnalyticsEvent.of("push_token_registered")
+ .put("platform", request.platform()));
}
@Operation(summary = "푸시 토큰 해제", description = "등록된 모든 푸시 토큰을 해제합니다.")
diff --git a/src/main/java/com/ject/vs/notification/adapter/web/NotificationController.java b/src/main/java/com/ject/vs/notification/adapter/web/NotificationController.java
index 15c01045..3217adf3 100644
--- a/src/main/java/com/ject/vs/notification/adapter/web/NotificationController.java
+++ b/src/main/java/com/ject/vs/notification/adapter/web/NotificationController.java
@@ -1,5 +1,7 @@
package com.ject.vs.notification.adapter.web;
+import com.ject.vs.analytics.AnalyticsEvent;
+import com.ject.vs.analytics.AnalyticsEventLogger;
import com.ject.vs.notification.adapter.web.dto.NotificationListResponse;
import com.ject.vs.notification.adapter.web.dto.ReadAllResponse;
import com.ject.vs.notification.adapter.web.dto.UnreadCountResponse;
@@ -21,6 +23,7 @@ public class NotificationController {
private final NotificationQueryUseCase queryUseCase;
private final NotificationCommandUseCase commandUseCase;
+ private final AnalyticsEventLogger analytics;
@Operation(summary = "알림 목록 조회", description = "알림 목록을 조회합니다. 커서 기반 페이지네이션을 지원합니다.")
@GetMapping
@@ -29,7 +32,19 @@ public NotificationListResponse getList(
@RequestParam(required = false) Long cursor,
@RequestParam(defaultValue = "20") int size) {
if (userId == null) throw new UnauthorizedException();
- return NotificationListResponse.from(queryUseCase.getList(userId, cursor, size));
+ NotificationListResponse response = NotificationListResponse.from(queryUseCase.getList(userId, cursor, size));
+
+ long unreadCount = response.notifications().stream()
+ .filter(n -> !n.isRead())
+ .count();
+
+ analytics.log(AnalyticsEvent.of("notification_list_viewed")
+ .put("notification_count", response.notifications().size())
+ .put("has_next", response.hasNext())
+ .put("next_cursor", response.nextCursor())
+ .put("unread_count", unreadCount));
+
+ return response;
}
@Operation(summary = "읽지 않은 알림 수 조회", description = "읽지 않은 알림의 개수를 조회합니다.")
@@ -46,7 +61,13 @@ public void markAsRead(
@PathVariable Long notificationId,
@AuthenticationPrincipal Long userId) {
if (userId == null) throw new UnauthorizedException();
- commandUseCase.markAsRead(notificationId, userId);
+ NotificationCommandUseCase.MarkAsReadResult result = commandUseCase.markAsRead(notificationId, userId);
+
+ analytics.log(AnalyticsEvent.of("notification_opened")
+ .put("notification_id", notificationId)
+ .put("notification_type", result.type())
+ .put("vote_id", result.voteId())
+ .put("is_read", result.wasRead()));
}
@Operation(summary = "모든 알림 읽음 처리", description = "모든 알림을 읽음 처리합니다.")
diff --git a/src/main/java/com/ject/vs/notification/port/NotificationCommandService.java b/src/main/java/com/ject/vs/notification/port/NotificationCommandService.java
index bb7db020..5b7b647a 100644
--- a/src/main/java/com/ject/vs/notification/port/NotificationCommandService.java
+++ b/src/main/java/com/ject/vs/notification/port/NotificationCommandService.java
@@ -22,11 +22,13 @@ public class NotificationCommandService implements NotificationCommandUseCase {
private final Clock clock;
@Override
- public void markAsRead(Long notificationId, Long userId) {
+ public MarkAsReadResult markAsRead(Long notificationId, Long userId) {
Notification n = notificationRepository.findById(notificationId)
.orElseThrow(NotificationNotFoundException::new);
if (!n.isOwnedBy(userId)) throw new NotificationNotFoundException();
+ boolean wasRead = n.isRead();
n.markRead(clock);
+ return new MarkAsReadResult(n.getType(), n.getVoteId(), wasRead);
}
@Override
diff --git a/src/main/java/com/ject/vs/notification/port/in/NotificationCommandUseCase.java b/src/main/java/com/ject/vs/notification/port/in/NotificationCommandUseCase.java
index bf221f59..e4b3932a 100644
--- a/src/main/java/com/ject/vs/notification/port/in/NotificationCommandUseCase.java
+++ b/src/main/java/com/ject/vs/notification/port/in/NotificationCommandUseCase.java
@@ -6,9 +6,17 @@
import java.util.List;
public interface NotificationCommandUseCase {
- void markAsRead(Long notificationId, Long userId);
+ /**
+ * 알림을 읽음 처리하고, 행동 로그(notification_opened)에 필요한 정보를 반환한다.
+ *
+ * @return 읽음 처리한 알림의 메타데이터(읽음 처리 직전 상태 기준의 isRead 포함)
+ */
+ MarkAsReadResult markAsRead(Long notificationId, Long userId);
int markAllAsRead(Long userId);
+ record MarkAsReadResult(NotificationType type, Long voteId, boolean wasRead) {
+ }
+
// 이벤트 핸들러용 — 생성된 Notification 목록 반환 (FCM 발송 시 notificationId 필요)
List createBatch(List commands);
diff --git a/src/main/java/com/ject/vs/user/domain/User.java b/src/main/java/com/ject/vs/user/domain/User.java
index 35a6585d..4e192c37 100644
--- a/src/main/java/com/ject/vs/user/domain/User.java
+++ b/src/main/java/com/ject/vs/user/domain/User.java
@@ -35,6 +35,12 @@ public class User {
private Instant withdrawnAt;
+ /** 가입 유입 출처(UTM). 가입(=row 최초 생성) 시점에 first-touch 값으로만 기록된다. */
+ private String signupSource;
+ private String signupMedium;
+ private String signupCampaign;
+ private String signupContent;
+
public static User createWithEmail(String email) {
User user = new User();
user.email = email;
@@ -90,4 +96,18 @@ public boolean isWithdrawn() {
return this.userStatus == UserStatus.WITHDRAWN;
}
+ /**
+ * 가입 유입 출처(UTM)를 기록한다. first-touch 이므로 신규 가입(row 최초 생성) 시 1회만 호출된다.
+ * 빈 출처면 아무것도 하지 않아 컬럼을 null로 유지한다.
+ */
+ public void assignSignupSource(UtmAttribution utm) {
+ if (utm == null || utm.isEmpty()) {
+ return;
+ }
+ this.signupSource = utm.source();
+ this.signupMedium = utm.medium();
+ this.signupCampaign = utm.campaign();
+ this.signupContent = utm.content();
+ }
+
}
diff --git a/src/main/java/com/ject/vs/user/domain/UtmAttribution.java b/src/main/java/com/ject/vs/user/domain/UtmAttribution.java
new file mode 100644
index 00000000..edd9dc6c
--- /dev/null
+++ b/src/main/java/com/ject/vs/user/domain/UtmAttribution.java
@@ -0,0 +1,30 @@
+package com.ject.vs.user.domain;
+
+/**
+ * 가입 유입 출처(UTM)를 담는 불변 값 객체.
+ *
+ * 비회원이 UTM 링크로 랜딩할 때 프론트가 {@code /api/track/visit} 로 넘긴 값이
+ * 쿠키에 박제됐다가, 소셜 로그인 가입이 완료되는 순간 {@link User} 에 기록된다.
+ *
+ *
빈 문자열은 {@link #of} 에서 null로 정규화해 의미 없는 값이 저장되지 않도록 한다.
+ */
+public record UtmAttribution(String source, String medium, String campaign, String content) {
+
+ private static final UtmAttribution EMPTY = new UtmAttribution(null, null, null, null);
+
+ public static UtmAttribution empty() {
+ return EMPTY;
+ }
+
+ public static UtmAttribution of(String source, String medium, String campaign, String content) {
+ return new UtmAttribution(blankToNull(source), blankToNull(medium), blankToNull(campaign), blankToNull(content));
+ }
+
+ public boolean isEmpty() {
+ return source == null && medium == null && campaign == null && content == null;
+ }
+
+ private static String blankToNull(String value) {
+ return (value == null || value.isBlank()) ? null : value;
+ }
+}
diff --git a/src/main/java/com/ject/vs/user/port/UserService.java b/src/main/java/com/ject/vs/user/port/UserService.java
index a7e8fd85..561fdfed 100644
--- a/src/main/java/com/ject/vs/user/port/UserService.java
+++ b/src/main/java/com/ject/vs/user/port/UserService.java
@@ -24,9 +24,18 @@ public class UserService {
private final UserImageService userImageService;
public User findOrCreate(String email) {
+ return findOrCreate(email, UtmAttribution.empty());
+ }
+
+ public User findOrCreate(String email, UtmAttribution utm) {
// 활성 사용자(탈퇴 제외)만 조회한다. 재가입은 기존 탈퇴 계정을 복구하지 않고 새 row를 생성한다.
+ // UTM 출처는 새 row를 만드는 경우(=신규 가입)에만 기록한다. 기존 사용자 재로그인 시엔 덮어쓰지 않는다(first-touch 고정).
return userRepository.findByEmailAndUserStatusNot(email, UserStatus.WITHDRAWN)
- .orElseGet(() -> userRepository.save(User.createWithEmail(email)));
+ .orElseGet(() -> {
+ User user = User.createWithEmail(email);
+ user.assignSignupSource(utm);
+ return userRepository.save(user);
+ });
}
public NicknameCheckResponse checkNickname(String nickName, Long userId) {
diff --git a/src/main/java/com/ject/vs/vote/adapter/web/GuestFreeVoteController.java b/src/main/java/com/ject/vs/vote/adapter/web/GuestFreeVoteController.java
index 3db7afed..f3e61e0a 100644
--- a/src/main/java/com/ject/vs/vote/adapter/web/GuestFreeVoteController.java
+++ b/src/main/java/com/ject/vs/vote/adapter/web/GuestFreeVoteController.java
@@ -1,5 +1,7 @@
package com.ject.vs.vote.adapter.web;
+import com.ject.vs.analytics.AnalyticsEvent;
+import com.ject.vs.analytics.AnalyticsEventLogger;
import com.ject.vs.config.AnonymousId;
import com.ject.vs.vote.adapter.web.dto.FreeVotesResponse;
import com.ject.vs.vote.domain.GuestFreeVote;
@@ -20,6 +22,7 @@
public class GuestFreeVoteController {
private final GuestFreeVoteService guestFreeVoteService;
+ private final AnalyticsEventLogger analytics;
@Operation(summary = "잔여 무료 투표권 조회", description = "비회원의 잔여 무료 투표권 수를 조회합니다. 회원은 remainingFreeVotes가 null로 응답됩니다.")
@GetMapping("/free-votes")
@@ -27,12 +30,17 @@ public FreeVotesResponse getFreeVotes(
@AuthenticationPrincipal Long userId,
@Parameter(hidden = true) @AnonymousId String anonymousId) {
// 회원은 무료 투표권 제한이 없으므로 null로 응답
- if (userId != null) {
- return new FreeVotesResponse(null, null);
- }
- return new FreeVotesResponse(
- guestFreeVoteService.remaining(anonymousId),
- GuestFreeVote.totalFreeVotes()
- );
+ FreeVotesResponse response = (userId != null)
+ ? new FreeVotesResponse(null, null)
+ : new FreeVotesResponse(
+ guestFreeVoteService.remaining(anonymousId),
+ GuestFreeVote.totalFreeVotes());
+
+ analytics.log(AnalyticsEvent.of("free_votes_checked")
+ .anonymousId(anonymousId)
+ .put("remaining_free_votes", response.remainingFreeVotes())
+ .put("total_free_votes", response.totalFreeVotes()));
+
+ return response;
}
}
diff --git a/src/main/java/com/ject/vs/vote/adapter/web/ImmersiveVoteController.java b/src/main/java/com/ject/vs/vote/adapter/web/ImmersiveVoteController.java
index c485fc42..218594ba 100644
--- a/src/main/java/com/ject/vs/vote/adapter/web/ImmersiveVoteController.java
+++ b/src/main/java/com/ject/vs/vote/adapter/web/ImmersiveVoteController.java
@@ -1,5 +1,7 @@
package com.ject.vs.vote.adapter.web;
+import com.ject.vs.analytics.AnalyticsEvent;
+import com.ject.vs.analytics.AnalyticsEventLogger;
import com.ject.vs.config.AnonymousId;
import com.ject.vs.vote.adapter.web.dto.ImmersiveFeedResponse;
import com.ject.vs.vote.adapter.web.dto.ImmersiveLiveResponse;
@@ -27,9 +29,12 @@
@RequiredArgsConstructor
public class ImmersiveVoteController {
+ private static final String VOTE_TYPE_IMMERSIVE = "IMMERSIVE";
+
private final ImmersiveVoteCommandUseCase immersiveVoteCommandUseCase;
private final ImmersiveVoteQueryUseCase immersiveVoteQueryUseCase;
private final VoteResultQueryUseCase voteResultQueryUseCase;
+ private final AnalyticsEventLogger analytics;
@Operation(summary = "몰입형 투표 피드 조회", description = "스와이프 형식의 몰입형 투표 피드를 조회합니다. 커서 기반 페이지네이션을 지원합니다. startVoteId를 지정하면 해당 투표부터 피드가 시작됩니다.")
@GetMapping
@@ -39,7 +44,16 @@ public ImmersiveFeedResponse getFeed(
@RequestParam(defaultValue = "10") int size,
@AuthenticationPrincipal Long userId,
@Parameter(hidden = true) @AnonymousId String anonymousId) {
- return ImmersiveFeedResponse.from(immersiveVoteQueryUseCase.getFeed(cursor, startVoteId, size, userId, anonymousId));
+ ImmersiveFeedResponse response = ImmersiveFeedResponse.from(
+ immersiveVoteQueryUseCase.getFeed(cursor, startVoteId, size, userId, anonymousId));
+
+ analytics.log(AnalyticsEvent.of("immersive_feed_viewed")
+ .anonymousId(anonymousId)
+ .put("loaded_vote_count", response.votes().size())
+ .put("next_cursor", response.nextCursor())
+ .put("has_next", response.hasNext()));
+
+ return response;
}
@Operation(summary = "투표 참여/취소", description = "투표에 참여하거나 같은 옵션 재클릭 시 취소합니다. 비회원은 5회까지 무료 투표 가능합니다.")
@@ -51,19 +65,45 @@ public ImmersiveParticipateResponse participateOrCancel(
@RequestBody @Valid ParticipateRequest request) {
ImmersiveVoteCommandUseCase.ImmersiveParticipateResult result =
immersiveVoteCommandUseCase.participateOrCancel(voteId, userId, anonymousId, request.optionId());
+
+ analytics.log(AnalyticsEvent.of("immersive_vote_participated")
+ .anonymousId(anonymousId)
+ .put("vote_id", result.voteId())
+ .put("option_id", request.optionId())
+ .put("action", result.action())
+ .put("selected_option_id", result.selectedOptionId())
+ .put("remaining_free_votes", result.remainingFreeVotes())
+ .put("vote_type", VOTE_TYPE_IMMERSIVE));
+
return ImmersiveParticipateResponse.from(result);
}
@Operation(summary = "실시간 투표 현황 조회", description = "투표 후 실시간 비율 갱신을 위한 폴링 API입니다.")
@GetMapping("/{voteId}/live")
public ImmersiveLiveResponse getLive(@PathVariable Long voteId) {
- return ImmersiveLiveResponse.from(immersiveVoteQueryUseCase.getLive(voteId));
+ ImmersiveLiveResponse response = ImmersiveLiveResponse.from(immersiveVoteQueryUseCase.getLive(voteId));
+
+ analytics.log(AnalyticsEvent.of("immersive_live_viewed")
+ .put("vote_id", voteId)
+ .put("current_viewer_count", response.currentViewerCount())
+ .put("total_participant_count", response.totalParticipantCount()));
+
+ return response;
}
@Operation(summary = "공유 링크 생성", description = "투표 공유를 위한 링크를 생성합니다.")
@GetMapping("/{voteId}/share")
public ShareLinkResponse getShareLink(@PathVariable Long voteId) {
- return ShareLinkResponse.from(voteResultQueryUseCase.getShareLink(voteId));
+ ShareLinkResponse response = ShareLinkResponse.from(voteResultQueryUseCase.getShareLink(voteId));
+
+ analytics.log(AnalyticsEvent.of("share_link_generated")
+ .put("vote_id", voteId)
+ .put("share_url", response.shareUrl())
+ .put("title", response.title())
+ .put("thumbnail_url", response.thumbnailUrl())
+ .put("vote_type", VOTE_TYPE_IMMERSIVE));
+
+ return response;
}
@Operation(summary = "랜덤 다음 투표 조회", description = "excludeIds를 제외한 진행 중인 투표를 랜덤으로 조회합니다. 모든 투표 소진 시 빈 배열 반환 → 클라이언트에서 excludeIds 초기화 후 재요청 (무한 순환)")
diff --git a/src/main/java/com/ject/vs/vote/adapter/web/VoteController.java b/src/main/java/com/ject/vs/vote/adapter/web/VoteController.java
index d9e36ab8..13979386 100644
--- a/src/main/java/com/ject/vs/vote/adapter/web/VoteController.java
+++ b/src/main/java/com/ject/vs/vote/adapter/web/VoteController.java
@@ -1,5 +1,7 @@
package com.ject.vs.vote.adapter.web;
+import com.ject.vs.analytics.AnalyticsEvent;
+import com.ject.vs.analytics.AnalyticsEventLogger;
import com.ject.vs.config.AnonymousId;
import com.ject.vs.vote.adapter.web.dto.*;
import com.ject.vs.vote.domain.Vote;
@@ -27,9 +29,12 @@
@RequiredArgsConstructor
public class VoteController {
+ private static final String VOTE_TYPE_GENERAL = "GENERAL";
+
private final VoteCommandUseCase voteCommandUseCase;
private final VoteDetailQueryService voteDetailQueryService;
private final VoteParticipationQueryUseCase voteParticipationQueryUseCase;
+ private final AnalyticsEventLogger analytics;
@Operation(summary = "투표 생성", description = "새로운 투표를 생성합니다. 회원만 가능합니다.")
@PostMapping
@@ -69,7 +74,18 @@ public VoteDetailResponse getDetail(
@PathVariable Long voteId,
@AuthenticationPrincipal Long userId,
@Parameter(hidden = true) @AnonymousId String anonymousId) {
- return VoteDetailResponse.from(voteDetailQueryService.getDetail(voteId, userId, anonymousId));
+ VoteDetailResponse response = VoteDetailResponse.from(voteDetailQueryService.getDetail(voteId, userId, anonymousId));
+
+ analytics.log(AnalyticsEvent.of("vote_detail_viewed")
+ .anonymousId(anonymousId)
+ .put("vote_id", response.voteId())
+ .put("vote_status", response.status())
+ .put("participant_count", response.participantCount())
+ .put("my_vote_voted", response.myVote().voted())
+ .put("selected_option_id", response.myVote().selectedOptionId())
+ .put("vote_type", VOTE_TYPE_GENERAL));
+
+ return response;
}
@Operation(summary = "투표 참여", description = "투표에 참여합니다. 비회원은 5회까지 무료 투표 가능합니다.")
@@ -82,6 +98,16 @@ public ParticipateResponse participate(
VoteCommandUseCase.ParticipateResult result = userId != null
? voteCommandUseCase.participateAsMember(voteId, userId, request.optionId())
: voteCommandUseCase.participateAsGuest(voteId, anonymousId, request.optionId());
+
+ analytics.log(AnalyticsEvent.of("vote_participated")
+ .anonymousId(anonymousId)
+ .put("vote_id", result.voteId())
+ .put("option_id", request.optionId())
+ .put("selected_option_id", result.selectedOptionId())
+ .put("participant_count", result.participantCount())
+ .put("remaining_free_votes", result.remainingFreeVotes())
+ .put("vote_type", VOTE_TYPE_GENERAL));
+
return ParticipateResponse.from(result);
}
@@ -92,7 +118,12 @@ public void cancel(
@PathVariable Long voteId,
@AuthenticationPrincipal Long userId) {
if (userId == null) throw new UnauthorizedException();
- voteCommandUseCase.cancel(voteId, userId);
+ Long previousOptionId = voteCommandUseCase.cancel(voteId, userId);
+
+ analytics.log(AnalyticsEvent.of("vote_canceled")
+ .put("vote_id", voteId)
+ .put("previous_option_id", previousOptionId)
+ .put("vote_type", VOTE_TYPE_GENERAL));
}
@GetMapping("/me/participated")
diff --git a/src/main/java/com/ject/vs/vote/adapter/web/VoteEmojiController.java b/src/main/java/com/ject/vs/vote/adapter/web/VoteEmojiController.java
index 801668d9..309f95d7 100644
--- a/src/main/java/com/ject/vs/vote/adapter/web/VoteEmojiController.java
+++ b/src/main/java/com/ject/vs/vote/adapter/web/VoteEmojiController.java
@@ -1,5 +1,7 @@
package com.ject.vs.vote.adapter.web;
+import com.ject.vs.analytics.AnalyticsEvent;
+import com.ject.vs.analytics.AnalyticsEventLogger;
import com.ject.vs.config.AnonymousId;
import com.ject.vs.vote.adapter.web.dto.EmojiRequest;
import com.ject.vs.vote.adapter.web.dto.EmojiResponse;
@@ -17,6 +19,7 @@
public class VoteEmojiController {
private final VoteEmojiCommandUseCase voteEmojiCommandUseCase;
+ private final AnalyticsEventLogger analytics;
@Operation(summary = "일반형 투표 이모지 반응", description = "이모지 반응을 추가/변경/취소합니��. 같은 이모지 재선택 또는 null 전송 시 취소됩니다.")
@PutMapping("/api/votes/{voteId}/emoji")
@@ -28,6 +31,7 @@ public EmojiResponse reactOnVote(
VoteEmojiCommandUseCase.EmojiResult result = userId != null
? voteEmojiCommandUseCase.reactAsMember(voteId, userId, request.emoji())
: voteEmojiCommandUseCase.reactAsGuest(voteId, anonymousId, request.emoji());
+ logEmojiReacted(voteId, anonymousId, request, result, "GENERAL");
return EmojiResponse.from(result);
}
@@ -41,6 +45,19 @@ public EmojiResponse reactOnImmersiveVote(
VoteEmojiCommandUseCase.EmojiResult result = userId != null
? voteEmojiCommandUseCase.reactAsMember(voteId, userId, request.emoji())
: voteEmojiCommandUseCase.reactAsGuest(voteId, anonymousId, request.emoji());
+ logEmojiReacted(voteId, anonymousId, request, result, "IMMERSIVE");
return EmojiResponse.from(result);
}
+
+ private void logEmojiReacted(Long voteId, String anonymousId, EmojiRequest request,
+ VoteEmojiCommandUseCase.EmojiResult result, String voteType) {
+ analytics.log(AnalyticsEvent.of("emoji_reacted")
+ .anonymousId(anonymousId)
+ .put("vote_id", voteId)
+ .put("emoji_type", request.emoji())
+ .put("my_emoji", result.myEmoji())
+ .put("emoji_total_count", result.total())
+ .put("action", result.action())
+ .put("vote_type", voteType));
+ }
}
diff --git a/src/main/java/com/ject/vs/vote/adapter/web/VoteResultController.java b/src/main/java/com/ject/vs/vote/adapter/web/VoteResultController.java
index 3eeb3ead..f7fc9a40 100644
--- a/src/main/java/com/ject/vs/vote/adapter/web/VoteResultController.java
+++ b/src/main/java/com/ject/vs/vote/adapter/web/VoteResultController.java
@@ -1,5 +1,7 @@
package com.ject.vs.vote.adapter.web;
+import com.ject.vs.analytics.AnalyticsEvent;
+import com.ject.vs.analytics.AnalyticsEventLogger;
import com.ject.vs.vote.adapter.web.dto.ShareLinkResponse;
import com.ject.vs.vote.adapter.web.dto.VoteResultResponse;
import com.ject.vs.vote.port.in.VoteResultQueryUseCase;
@@ -15,19 +17,46 @@
@RequiredArgsConstructor
public class VoteResultController {
+ private static final String VOTE_TYPE_GENERAL = "GENERAL";
+
private final VoteResultQueryUseCase voteResultQueryUseCase;
+ private final AnalyticsEventLogger analytics;
@Operation(summary = "투표 결과 조회", description = "마감된 투표의 결과를 조회합니다. 진행 중 투표는 403 응답합니다. 비회원은 insight가 잠금 상태로 응답됩니다.")
@GetMapping("/result")
public VoteResultResponse getResult(
@PathVariable Long voteId,
@AuthenticationPrincipal Long userId) {
- return VoteResultResponse.from(voteResultQueryUseCase.getResult(voteId, userId));
+ VoteResultResponse response = VoteResultResponse.from(voteResultQueryUseCase.getResult(voteId, userId));
+
+ var insight = response.insight();
+ var aiInsight = response.aiInsight();
+ analytics.log(AnalyticsEvent.of("result_page_viewed")
+ .put("vote_id", response.voteId())
+ .put("vote_status", response.status())
+ .put("participant_count", response.participantCount())
+ .put("my_vote_voted", response.myVote().voted())
+ .put("selected_option_id", response.myVote().selectedOptionId())
+ .put("insight_locked", insight != null ? insight.locked() : null)
+ .put("insight_scope", insight != null ? insight.scope() : null)
+ .put("selection_count", insight != null ? insight.selectionCount() : null)
+ .put("ai_insight_available", aiInsight != null && aiInsight.available()));
+
+ return response;
}
@Operation(summary = "공유 링크 생성", description = "투표 공유를 위한 링크를 생성합니다.")
@GetMapping("/share")
public ShareLinkResponse getShareLink(@PathVariable Long voteId) {
- return ShareLinkResponse.from(voteResultQueryUseCase.getShareLink(voteId));
+ ShareLinkResponse response = ShareLinkResponse.from(voteResultQueryUseCase.getShareLink(voteId));
+
+ analytics.log(AnalyticsEvent.of("share_link_generated")
+ .put("vote_id", voteId)
+ .put("share_url", response.shareUrl())
+ .put("title", response.title())
+ .put("thumbnail_url", response.thumbnailUrl())
+ .put("vote_type", VOTE_TYPE_GENERAL));
+
+ return response;
}
}
diff --git a/src/main/java/com/ject/vs/vote/domain/EmojiAction.java b/src/main/java/com/ject/vs/vote/domain/EmojiAction.java
new file mode 100644
index 00000000..b92bdcb9
--- /dev/null
+++ b/src/main/java/com/ject/vs/vote/domain/EmojiAction.java
@@ -0,0 +1,14 @@
+package com.ject.vs.vote.domain;
+
+/**
+ * 이모지 반응 동작 분류. 행동 로그(emoji_reacted)의 {@code action} 변수로 사용된다.
+ * 사용자의 이전 반응 상태와 비교하여 결정된다.
+ */
+public enum EmojiAction {
+ /** 기존 반응이 없던 상태에서 새로 등록 */
+ CREATED,
+ /** 다른 이모지로 교체 */
+ CHANGED,
+ /** 기존 반응을 취소(같은 이모지 재클릭 또는 null 전송) */
+ CANCELED
+}
diff --git a/src/main/java/com/ject/vs/vote/domain/VoteDuration.java b/src/main/java/com/ject/vs/vote/domain/VoteDuration.java
index cf5477ca..2dede707 100644
--- a/src/main/java/com/ject/vs/vote/domain/VoteDuration.java
+++ b/src/main/java/com/ject/vs/vote/domain/VoteDuration.java
@@ -11,7 +11,8 @@
@RequiredArgsConstructor
public enum VoteDuration {
HOURS_12(Duration.ofHours(12)),
- HOURS_24(Duration.ofHours(24));
+ HOURS_24(Duration.ofHours(24)),
+ HOURS_72(Duration.ofHours(72));
private final Duration value;
diff --git a/src/main/java/com/ject/vs/vote/port/VoteCommandService.java b/src/main/java/com/ject/vs/vote/port/VoteCommandService.java
index ed23a5f9..ecb3d886 100644
--- a/src/main/java/com/ject/vs/vote/port/VoteCommandService.java
+++ b/src/main/java/com/ject/vs/vote/port/VoteCommandService.java
@@ -95,9 +95,13 @@ public ParticipateResult participateAsGuest(Long voteId, String anonymousId, Lon
}
@Override
- public void cancel(Long voteId, Long userId) {
+ public Long cancel(Long voteId, Long userId) {
loadOngoingVote(voteId);
+ Long previousOptionId = voteParticipationRepository.findByVoteIdAndUserId(voteId, userId)
+ .map(VoteParticipation::getOptionId)
+ .orElse(null);
voteParticipationRepository.deleteByVoteIdAndUserId(voteId, userId);
+ return previousOptionId;
}
private Vote loadOngoingVote(Long voteId) {
diff --git a/src/main/java/com/ject/vs/vote/port/VoteEmojiCommandService.java b/src/main/java/com/ject/vs/vote/port/VoteEmojiCommandService.java
index b5310cbc..ebc869ad 100644
--- a/src/main/java/com/ject/vs/vote/port/VoteEmojiCommandService.java
+++ b/src/main/java/com/ject/vs/vote/port/VoteEmojiCommandService.java
@@ -28,8 +28,9 @@ public EmojiResult reactAsMember(Long voteId, Long userId, VoteEmoji emoji) {
Vote vote = voteRepository.findById(voteId).orElseThrow(VoteNotFoundException::new);
if (vote.isEnded(clock)) throw new VoteEndedException();
Optional existing = reactionRepository.findByVoteIdAndUserId(voteId, userId);
+ EmojiAction action = resolveAction(existing, emoji);
VoteEmojiReaction resultEmoji = applyReaction(existing, emoji != null ? VoteEmojiReaction.ofMember(voteId, userId, emoji) : null);
- return buildResult(voteId, resultEmoji != null ? resultEmoji.getEmoji() : null);
+ return buildResult(voteId, resultEmoji != null ? resultEmoji.getEmoji() : null, action);
}
@Override
@@ -37,11 +38,29 @@ public EmojiResult reactAsGuest(Long voteId, String anonymousId, VoteEmoji emoji
Vote vote = voteRepository.findById(voteId).orElseThrow(VoteNotFoundException::new);
if (vote.isEnded(clock)) throw new VoteEndedException();
Optional existing = reactionRepository.findByVoteIdAndAnonymousId(voteId, anonymousId);
+ EmojiAction action = resolveAction(existing, emoji);
VoteEmojiReaction resultEmoji = applyReaction(
existing,
emoji != null ? VoteEmojiReaction.ofGuest(voteId, anonymousId, emoji) : null
);
- return buildResult(voteId, resultEmoji != null ? resultEmoji.getEmoji() : null);
+ return buildResult(voteId, resultEmoji != null ? resultEmoji.getEmoji() : null, action);
+ }
+
+ /**
+ * 행동 로그(emoji_reacted)의 action 변수를 위해, 저장 전 이전 반응 상태와 비교하여 동작을 분류한다.
+ * - 기존 없음 + emoji 있음 → CREATED
+ * - 기존 있음 + (emoji 없음 or 같은 emoji 재클릭) → CANCELED
+ * - 기존 있음 + 다른 emoji → CHANGED
+ * - 기존 없음 + emoji 없음 → CANCELED (멱등 no-op)
+ */
+ private EmojiAction resolveAction(Optional existing, VoteEmoji emoji) {
+ if (existing.isEmpty()) {
+ return emoji != null ? EmojiAction.CREATED : EmojiAction.CANCELED;
+ }
+ if (emoji == null || existing.get().getEmoji() == emoji) {
+ return EmojiAction.CANCELED;
+ }
+ return EmojiAction.CHANGED;
}
/**
@@ -73,13 +92,13 @@ private VoteEmojiReaction applyReaction(Optional existing,
return existingReaction;
}
- private EmojiResult buildResult(Long voteId, VoteEmoji myEmoji) {
+ private EmojiResult buildResult(Long voteId, VoteEmoji myEmoji, EmojiAction action) {
Map summary = VoteEmoji.getMap();
reactionRepository.countByEmojiForVote(voteId)
.forEach(row -> summary.put(row.emoij(), row.count()));
long total = summary.values().stream().mapToLong(Long::longValue).sum();
- return new EmojiResult(summary, total, myEmoji);
+ return new EmojiResult(summary, total, myEmoji, action);
}
}
diff --git a/src/main/java/com/ject/vs/vote/port/in/VoteCommandUseCase.java b/src/main/java/com/ject/vs/vote/port/in/VoteCommandUseCase.java
index 20541d78..7bc1409c 100644
--- a/src/main/java/com/ject/vs/vote/port/in/VoteCommandUseCase.java
+++ b/src/main/java/com/ject/vs/vote/port/in/VoteCommandUseCase.java
@@ -19,7 +19,12 @@ public interface VoteCommandUseCase {
ParticipateResult participateAsGuest(Long voteId, String anonymousId, Long optionId);
- void cancel(Long voteId, Long userId);
+ /**
+ * 투표 참여를 취소한다.
+ *
+ * @return 취소 직전 선택했던 옵션 ID(행동 로그 vote_canceled의 previous_option_id). 참여 내역이 없으면 null.
+ */
+ Long cancel(Long voteId, Long userId);
record VoteCreateCommand(
String title,
diff --git a/src/main/java/com/ject/vs/vote/port/in/VoteEmojiCommandUseCase.java b/src/main/java/com/ject/vs/vote/port/in/VoteEmojiCommandUseCase.java
index be8fcdc1..abf335bd 100644
--- a/src/main/java/com/ject/vs/vote/port/in/VoteEmojiCommandUseCase.java
+++ b/src/main/java/com/ject/vs/vote/port/in/VoteEmojiCommandUseCase.java
@@ -1,5 +1,6 @@
package com.ject.vs.vote.port.in;
+import com.ject.vs.vote.domain.EmojiAction;
import com.ject.vs.vote.domain.VoteEmoji;
import java.util.Map;
@@ -12,6 +13,9 @@ public interface VoteEmojiCommandUseCase {
/** emoji == null 이면 취소 */
EmojiResult reactAsGuest(Long voteId, String anonymousId, VoteEmoji emoji);
- record EmojiResult(Map emojiSummary, long total, VoteEmoji myEmoji) {
+ /**
+ * @param action 이전 반응 상태와 비교한 동작 분류(행동 로그 emoji_reacted의 action 변수)
+ */
+ record EmojiResult(Map emojiSummary, long total, VoteEmoji myEmoji, EmojiAction action) {
}
}
diff --git a/src/main/resources/db/migration/V14__add_user_signup_source.sql b/src/main/resources/db/migration/V14__add_user_signup_source.sql
new file mode 100644
index 00000000..b997a2e9
--- /dev/null
+++ b/src/main/resources/db/migration/V14__add_user_signup_source.sql
@@ -0,0 +1,8 @@
+-- 가입 출처(UTM) 컬럼 추가.
+-- 비회원이 UTM 링크로 유입(/api/track/visit)되면 백엔드가 first-touch UTM을 쿠키에 박제하고,
+-- 소셜 로그인 가입이 "처음 완료되는 순간"(users row 최초 생성 시)에만 아래 컬럼에 기록한다.
+-- 기존 사용자 재로그인 시에는 덮어쓰지 않는다(first-touch 고정).
+ALTER TABLE users ADD COLUMN signup_source VARCHAR(255);
+ALTER TABLE users ADD COLUMN signup_medium VARCHAR(255);
+ALTER TABLE users ADD COLUMN signup_campaign VARCHAR(255);
+ALTER TABLE users ADD COLUMN signup_content VARCHAR(255);
diff --git a/src/unitTest/java/com/ject/vs/chat/adapter/web/ChatControllerTest.java b/src/unitTest/java/com/ject/vs/chat/adapter/web/ChatControllerTest.java
index 0fca2b5c..5950b1f3 100644
--- a/src/unitTest/java/com/ject/vs/chat/adapter/web/ChatControllerTest.java
+++ b/src/unitTest/java/com/ject/vs/chat/adapter/web/ChatControllerTest.java
@@ -1,6 +1,7 @@
package com.ject.vs.chat.adapter.web;
import com.fasterxml.jackson.databind.ObjectMapper;
+import com.ject.vs.analytics.AnalyticsEventLogger;
import com.ject.vs.chat.adapter.web.dto.MarkAsReadRequest;
import com.ject.vs.chat.adapter.web.dto.SendMessageRequest;
import com.ject.vs.chat.exception.ChatForbiddenException;
@@ -61,6 +62,9 @@ class ChatControllerTest {
@MockBean
private CustomOAuth2UserService customOAuth2UserService;
+ @MockBean
+ private AnalyticsEventLogger analytics;
+
@Nested
class getChatList {
@@ -145,7 +149,7 @@ class sendMessage {
@WithMockUser
void 정상이면_201을_반환한다() throws Exception {
// given
- MessageResult result = new MessageResult(1L, "hello", Instant.now(), "nick", null, VoteOptionCode.A, true);
+ MessageResult result = new MessageResult(1L, "hello", Instant.now(), "nick", null, VoteOptionCode.A, true, false);
given(chatCommandUseCase.sendMessage(any(SendMessageCommand.class))).willReturn(result);
// when & then
diff --git a/src/unitTest/java/com/ject/vs/notification/adapter/web/AdminPushControllerTest.java b/src/unitTest/java/com/ject/vs/notification/adapter/web/AdminPushControllerTest.java
index 25fec808..e24e68ac 100644
--- a/src/unitTest/java/com/ject/vs/notification/adapter/web/AdminPushControllerTest.java
+++ b/src/unitTest/java/com/ject/vs/notification/adapter/web/AdminPushControllerTest.java
@@ -1,6 +1,7 @@
package com.ject.vs.notification.adapter.web;
import com.fasterxml.jackson.databind.ObjectMapper;
+import com.ject.vs.analytics.AnalyticsEventLogger;
import com.ject.vs.auth.port.CustomOAuth2UserService;
import com.ject.vs.config.OAuth2LoginSuccessHandler;
import com.ject.vs.common.exception.GlobalExceptionHandler;
@@ -60,6 +61,9 @@ class AdminPushControllerTest {
@MockBean
private PushSenderPort pushSenderPort;
+ @MockBean
+ private AnalyticsEventLogger analytics;
+
private static final UsernamePasswordAuthenticationToken ADMIN_AUTH =
new UsernamePasswordAuthenticationToken(1L, null, Collections.emptyList());
diff --git a/src/unitTest/java/com/ject/vs/notification/adapter/web/NotificationControllerTest.java b/src/unitTest/java/com/ject/vs/notification/adapter/web/NotificationControllerTest.java
index b0f4414d..12f82467 100644
--- a/src/unitTest/java/com/ject/vs/notification/adapter/web/NotificationControllerTest.java
+++ b/src/unitTest/java/com/ject/vs/notification/adapter/web/NotificationControllerTest.java
@@ -1,5 +1,6 @@
package com.ject.vs.notification.adapter.web;
+import com.ject.vs.analytics.AnalyticsEventLogger;
import com.ject.vs.auth.port.CustomOAuth2UserService;
import com.ject.vs.config.OAuth2LoginSuccessHandler;
import com.ject.vs.common.exception.GlobalExceptionHandler;
@@ -64,6 +65,9 @@ class NotificationControllerTest {
@MockBean
private PushSenderPort pushSenderPort;
+ @MockBean
+ private AnalyticsEventLogger analytics;
+
private static final UsernamePasswordAuthenticationToken AUTH =
new UsernamePasswordAuthenticationToken(1L, null, Collections.emptyList());
@@ -138,6 +142,9 @@ class MarkAsRead {
@Test
@DisplayName("알림을 읽음 처리한다")
void marks_notification_as_read() throws Exception {
+ given(commandUseCase.markAsRead(1L, 1L)).willReturn(
+ new NotificationCommandUseCase.MarkAsReadResult(NotificationType.VOTE_ENDED, 100L, false));
+
mockMvc.perform(post("/api/notifications/1/read")
.with(authentication(AUTH))
.with(csrf()))
diff --git a/src/unitTest/java/com/ject/vs/user/adapter/web/UserControllerTest.java b/src/unitTest/java/com/ject/vs/user/adapter/web/UserControllerTest.java
index 168d081f..23df765a 100644
--- a/src/unitTest/java/com/ject/vs/user/adapter/web/UserControllerTest.java
+++ b/src/unitTest/java/com/ject/vs/user/adapter/web/UserControllerTest.java
@@ -1,6 +1,7 @@
package com.ject.vs.user.adapter.web;
import com.fasterxml.jackson.databind.ObjectMapper;
+import com.ject.vs.analytics.AnalyticsEventLogger;
import com.ject.vs.auth.port.CustomOAuth2UserService;
import com.ject.vs.config.OAuth2LoginSuccessHandler;
import com.ject.vs.config.TestPropertiesConfig;
@@ -54,6 +55,9 @@ class UserControllerTest {
@MockitoBean
private CustomOAuth2UserService customOAuth2UserService;
+ @MockitoBean
+ private AnalyticsEventLogger analytics;
+
@Autowired
private ObjectMapper objectMapper;
diff --git a/src/unitTest/java/com/ject/vs/vote/adapter/web/GuestFreeVoteControllerTest.java b/src/unitTest/java/com/ject/vs/vote/adapter/web/GuestFreeVoteControllerTest.java
index f6b17b6c..a7cd25ca 100644
--- a/src/unitTest/java/com/ject/vs/vote/adapter/web/GuestFreeVoteControllerTest.java
+++ b/src/unitTest/java/com/ject/vs/vote/adapter/web/GuestFreeVoteControllerTest.java
@@ -1,5 +1,6 @@
package com.ject.vs.vote.adapter.web;
+import com.ject.vs.analytics.AnalyticsEventLogger;
import com.ject.vs.config.OAuth2LoginSuccessHandler;
import com.ject.vs.config.TestPropertiesConfig;
import org.springframework.context.annotation.Import;
@@ -30,6 +31,7 @@ class GuestFreeVoteControllerTest {
@MockBean CookieUtil cookieUtil;
@MockBean OAuth2LoginSuccessHandler oAuth2LoginSuccessHandler;
@MockBean CustomOAuth2UserService customOAuth2UserService;
+ @MockBean AnalyticsEventLogger analytics;
@Test
@WithMockUser
diff --git a/src/unitTest/java/com/ject/vs/vote/adapter/web/ImmersiveVoteControllerTest.java b/src/unitTest/java/com/ject/vs/vote/adapter/web/ImmersiveVoteControllerTest.java
index f1959716..bdb2343d 100644
--- a/src/unitTest/java/com/ject/vs/vote/adapter/web/ImmersiveVoteControllerTest.java
+++ b/src/unitTest/java/com/ject/vs/vote/adapter/web/ImmersiveVoteControllerTest.java
@@ -1,6 +1,7 @@
package com.ject.vs.vote.adapter.web;
import com.fasterxml.jackson.databind.ObjectMapper;
+import com.ject.vs.analytics.AnalyticsEventLogger;
import com.ject.vs.config.OAuth2LoginSuccessHandler;
import com.ject.vs.config.TestPropertiesConfig;
import org.springframework.context.annotation.Import;
@@ -61,6 +62,7 @@ class ImmersiveVoteControllerTest {
@MockBean CookieUtil cookieUtil;
@MockBean OAuth2LoginSuccessHandler oAuth2LoginSuccessHandler;
@MockBean CustomOAuth2UserService customOAuth2UserService;
+ @MockBean AnalyticsEventLogger analytics;
private static final UsernamePasswordAuthenticationToken AUTH =
new UsernamePasswordAuthenticationToken(1L, null, Collections.emptyList());
diff --git a/src/unitTest/java/com/ject/vs/vote/adapter/web/VoteControllerTest.java b/src/unitTest/java/com/ject/vs/vote/adapter/web/VoteControllerTest.java
index 5d33d6e4..fce686d6 100644
--- a/src/unitTest/java/com/ject/vs/vote/adapter/web/VoteControllerTest.java
+++ b/src/unitTest/java/com/ject/vs/vote/adapter/web/VoteControllerTest.java
@@ -1,6 +1,7 @@
package com.ject.vs.vote.adapter.web;
import com.fasterxml.jackson.databind.ObjectMapper;
+import com.ject.vs.analytics.AnalyticsEventLogger;
import com.ject.vs.config.OAuth2LoginSuccessHandler;
import com.ject.vs.config.TestPropertiesConfig;
import com.ject.vs.notification.port.out.PushSenderPort;
@@ -62,6 +63,7 @@ class VoteControllerTest {
@MockBean CustomOAuth2UserService customOAuth2UserService;
@MockBean PushSenderPort pushSenderPort;
@MockBean VoteParticipationQueryUseCase voteParticipationQueryUseCase;
+ @MockBean AnalyticsEventLogger analytics;
private static final UsernamePasswordAuthenticationToken AUTH =
new UsernamePasswordAuthenticationToken(1L, null, Collections.emptyList());
@@ -168,7 +170,7 @@ class cancel {
@Test
void 회원_취소_204_반환() throws Exception {
- willDoNothing().given(voteCommandUseCase).cancel(eq(1L), eq(1L));
+ given(voteCommandUseCase.cancel(eq(1L), eq(1L))).willReturn(10L);
mockMvc.perform(delete("/api/votes/1/participate")
.with(authentication(AUTH)).with(csrf()))
diff --git a/src/unitTest/java/com/ject/vs/vote/adapter/web/VoteEmojiControllerTest.java b/src/unitTest/java/com/ject/vs/vote/adapter/web/VoteEmojiControllerTest.java
index 3427c8d9..fe9792a5 100644
--- a/src/unitTest/java/com/ject/vs/vote/adapter/web/VoteEmojiControllerTest.java
+++ b/src/unitTest/java/com/ject/vs/vote/adapter/web/VoteEmojiControllerTest.java
@@ -1,6 +1,7 @@
package com.ject.vs.vote.adapter.web;
import com.fasterxml.jackson.databind.ObjectMapper;
+import com.ject.vs.analytics.AnalyticsEventLogger;
import com.ject.vs.config.OAuth2LoginSuccessHandler;
import com.ject.vs.config.TestPropertiesConfig;
import org.springframework.context.annotation.Import;
@@ -8,6 +9,7 @@
import com.ject.vs.util.CookieUtil;
import com.ject.vs.util.JwtProvider;
import com.ject.vs.vote.adapter.web.dto.EmojiRequest;
+import com.ject.vs.vote.domain.EmojiAction;
import com.ject.vs.vote.domain.VoteEmoji;
import com.ject.vs.vote.exception.VoteNotFoundException;
import com.ject.vs.vote.port.in.VoteEmojiCommandUseCase;
@@ -44,13 +46,14 @@ class VoteEmojiControllerTest {
@MockBean CookieUtil cookieUtil;
@MockBean OAuth2LoginSuccessHandler oAuth2LoginSuccessHandler;
@MockBean CustomOAuth2UserService customOAuth2UserService;
+ @MockBean AnalyticsEventLogger analytics;
private static final UsernamePasswordAuthenticationToken AUTH =
new UsernamePasswordAuthenticationToken(1L, null, Collections.emptyList());
private EmojiResult sampleResult(VoteEmoji myEmoji) {
return new EmojiResult(Map.of(VoteEmoji.LIKE, 5L, VoteEmoji.SAD, 0L,
- VoteEmoji.ANGRY, 0L, VoteEmoji.WOW, 0L), 5L, myEmoji);
+ VoteEmoji.ANGRY, 0L, VoteEmoji.WOW, 0L), 5L, myEmoji, EmojiAction.CREATED);
}
@Nested
@@ -135,7 +138,7 @@ class 이모지_교체_및_취소 {
void 다른_이모지_선택시_기존_반응_자동_교체() throws Exception {
EmojiResult result = new EmojiResult(
Map.of(VoteEmoji.LIKE, 4L, VoteEmoji.SAD, 0L, VoteEmoji.ANGRY, 0L, VoteEmoji.WOW, 1L),
- 5L, VoteEmoji.WOW
+ 5L, VoteEmoji.WOW, EmojiAction.CHANGED
);
given(voteEmojiCommandUseCase.reactAsMember(eq(1L), eq(1L), eq(VoteEmoji.WOW)))
.willReturn(result);
@@ -155,7 +158,7 @@ class 이모지_교체_및_취소 {
void 같은_이모지_재클릭시_취소() throws Exception {
EmojiResult result = new EmojiResult(
Map.of(VoteEmoji.LIKE, 4L, VoteEmoji.SAD, 0L, VoteEmoji.ANGRY, 0L, VoteEmoji.WOW, 0L),
- 4L, null
+ 4L, null, EmojiAction.CANCELED
);
given(voteEmojiCommandUseCase.reactAsMember(eq(1L), eq(1L), eq(VoteEmoji.WOW)))
.willReturn(result);
diff --git a/src/unitTest/java/com/ject/vs/vote/adapter/web/VoteResultControllerTest.java b/src/unitTest/java/com/ject/vs/vote/adapter/web/VoteResultControllerTest.java
index 0a396322..e9c18028 100644
--- a/src/unitTest/java/com/ject/vs/vote/adapter/web/VoteResultControllerTest.java
+++ b/src/unitTest/java/com/ject/vs/vote/adapter/web/VoteResultControllerTest.java
@@ -1,5 +1,6 @@
package com.ject.vs.vote.adapter.web;
+import com.ject.vs.analytics.AnalyticsEventLogger;
import com.ject.vs.config.OAuth2LoginSuccessHandler;
import com.ject.vs.config.TestPropertiesConfig;
import org.springframework.context.annotation.Import;
@@ -48,6 +49,7 @@ class VoteResultControllerTest {
@MockBean CookieUtil cookieUtil;
@MockBean OAuth2LoginSuccessHandler oAuth2LoginSuccessHandler;
@MockBean CustomOAuth2UserService customOAuth2UserService;
+ @MockBean AnalyticsEventLogger analytics;
private static final UsernamePasswordAuthenticationToken AUTH =
new UsernamePasswordAuthenticationToken(1L, null, Collections.emptyList());