From bbf6203009059fa7b1103c91e6c54758f7ac3e61 Mon Sep 17 00:00:00 2001 From: junhyukkkk Date: Mon, 8 Jun 2026 01:50:16 +0900 Subject: [PATCH 01/16] =?UTF-8?q?feat:=20=ED=96=89=EB=8F=99=20=EB=A1=9C?= =?UTF-8?q?=EA=B7=B8(analytics=20event)=20=20=20=EB=A1=9C=EA=B9=85=20?= =?UTF-8?q?=EC=9D=B8=ED=94=84=EB=9D=BC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/ject/vs/analytics/AnalyticsEvent.java | 81 +++++++++++++++++++ 1 file changed, 81 insertions(+) create mode 100644 src/main/java/com/ject/vs/analytics/AnalyticsEvent.java diff --git a/src/main/java/com/ject/vs/analytics/AnalyticsEvent.java b/src/main/java/com/ject/vs/analytics/AnalyticsEvent.java new file mode 100644 index 0000000..6bd808b --- /dev/null +++ b/src/main/java/com/ject/vs/analytics/AnalyticsEvent.java @@ -0,0 +1,81 @@ +package com.ject.vs.analytics; + +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * 행동 로그 한 건을 표현하는 값 객체. 이벤트 이름과 이벤트별 로그 변수를 담는다. + * + *

공통 로그 변수(user_id / anonymous_id / is_member / platform / occurred_at)는 + * {@link AnalyticsEventLogger#log(AnalyticsEvent)} 시점에 채워지므로 여기서는 다루지 않는다. + * + *

로거에 의존하지 않는 순수 값 객체이므로, 로거를 목(mock)으로 주입한 테스트에서도 + * {@code log(...)} 가 no-op이 되어 NPE 없이 안전하다. + * + *

{@code
+ * analytics.log(AnalyticsEvent.of("vote_detail_viewed")
+ *         .put("vote_id", voteId)
+ *         .put("vote_status", status));
+ * }
+ */ +public final class AnalyticsEvent { + + private final String name; + private final Map properties = new LinkedHashMap<>(); + private Long userId; + private boolean userIdOverridden; + private String anonymousId; + private boolean anonymousIdOverridden; + + private AnalyticsEvent(String name) { + this.name = name; + } + + public static AnalyticsEvent of(String name) { + return new AnalyticsEvent(name); + } + + /** 이벤트별 로그 변수 추가. null 값도 그대로 기록한다. */ + public AnalyticsEvent put(String key, Object value) { + properties.put(key, value); + return this; + } + + /** SecurityContext에서 user_id를 얻을 수 없는 흐름(예: OAuth 로그인 성공 핸들러)에서 직접 지정. */ + public AnalyticsEvent userId(Long userId) { + this.userId = userId; + this.userIdOverridden = true; + return this; + } + + /** 컨트롤러가 이미 보유한 anonymous_id를 직접 지정(쿠키 재파싱 생략). */ + public AnalyticsEvent anonymousId(String anonymousId) { + this.anonymousId = anonymousId; + this.anonymousIdOverridden = true; + return this; + } + + String name() { + return name; + } + + Map properties() { + return properties; + } + + boolean userIdOverridden() { + return userIdOverridden; + } + + Long userId() { + return userId; + } + + boolean anonymousIdOverridden() { + return anonymousIdOverridden; + } + + String anonymousId() { + return anonymousId; + } +} From be09d569541a8fcbfa9b7a6aaea81b698c0b9b1c Mon Sep 17 00:00:00 2001 From: junhyukkkk Date: Mon, 8 Jun 2026 01:50:28 +0900 Subject: [PATCH 02/16] =?UTF-8?q?feat:=20=EC=9D=BC=EB=B0=98=20=ED=88=AC?= =?UTF-8?q?=ED=91=9C=20=ED=96=89=EB=8F=99=20=EB=A1=9C=EA=B7=B8=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20(vote=5Fdetail/participate/cancel)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../vs/vote/adapter/web/VoteController.java | 35 +++++++++++++++++-- .../ject/vs/vote/port/VoteCommandService.java | 6 +++- .../vs/vote/port/in/VoteCommandUseCase.java | 7 +++- 3 files changed, 44 insertions(+), 4 deletions(-) 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 d9e36ab..1397938 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/port/VoteCommandService.java b/src/main/java/com/ject/vs/vote/port/VoteCommandService.java index ed23a5f..ecb3d88 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/in/VoteCommandUseCase.java b/src/main/java/com/ject/vs/vote/port/in/VoteCommandUseCase.java index 20541d7..7bc1409 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, From ac0759f011031d56854e23503cdb34ee39e5c13b Mon Sep 17 00:00:00 2001 From: junhyukkkk Date: Mon, 8 Jun 2026 01:52:39 +0900 Subject: [PATCH 03/16] =?UTF-8?q?feat:=20=EC=9D=B4=EB=AA=A8=EC=A7=80=20?= =?UTF-8?q?=ED=96=89=EB=8F=99=20=EB=A1=9C=EA=B7=B8=20=EC=B6=94=EA=B0=80=20?= =?UTF-8?q?(emoji=5Freacted,=20=EC=9D=B4=EC=A0=84=20=EC=83=81=ED=83=9C=20?= =?UTF-8?q?=EB=B9=84=EA=B5=90=20action)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../vote/adapter/web/VoteEmojiController.java | 17 ++++++++++++ .../com/ject/vs/vote/domain/EmojiAction.java | 14 ++++++++++ .../vs/vote/port/VoteEmojiCommandService.java | 27 ++++++++++++++++--- .../vote/port/in/VoteEmojiCommandUseCase.java | 6 ++++- 4 files changed, 59 insertions(+), 5 deletions(-) create mode 100644 src/main/java/com/ject/vs/vote/domain/EmojiAction.java 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 801668d..309f95d 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/domain/EmojiAction.java b/src/main/java/com/ject/vs/vote/domain/EmojiAction.java new file mode 100644 index 0000000..b92bdcb --- /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/port/VoteEmojiCommandService.java b/src/main/java/com/ject/vs/vote/port/VoteEmojiCommandService.java index b5310cb..ebc869a 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/VoteEmojiCommandUseCase.java b/src/main/java/com/ject/vs/vote/port/in/VoteEmojiCommandUseCase.java index be8fcdc..abf335b 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) { } } From 8013a809ab9bb2d47b486c5d97aa94fcd5b4c9c8 Mon Sep 17 00:00:00 2001 From: junhyukkkk Date: Mon, 8 Jun 2026 01:52:47 +0900 Subject: [PATCH 04/16] =?UTF-8?q?feat:=20=ED=88=AC=ED=91=9C=20=EA=B2=B0?= =?UTF-8?q?=EA=B3=BC/=EA=B3=B5=EC=9C=A0=20=ED=96=89=EB=8F=99=20=EB=A1=9C?= =?UTF-8?q?=EA=B7=B8=20=EC=B6=94=EA=B0=80=20(result=5Fpage=5Fviewed,=20sha?= =?UTF-8?q?re=5Flink=5Fgenerated)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../adapter/web/VoteResultController.java | 33 +++++++++++++++++-- 1 file changed, 31 insertions(+), 2 deletions(-) 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 3eeb3ea..f7fc9a4 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; } } From f812f2e3594205d0e8ce619894565bde49b9779e Mon Sep 17 00:00:00 2001 From: junhyukkkk Date: Mon, 8 Jun 2026 01:52:53 +0900 Subject: [PATCH 05/16] =?UTF-8?q?feat:=20=EB=AA=B0=EC=9E=85=ED=98=95=20?= =?UTF-8?q?=ED=88=AC=ED=91=9C=20=ED=96=89=EB=8F=99=20=EB=A1=9C=EA=B7=B8=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20(feed/participated/live)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../adapter/web/ImmersiveVoteController.java | 46 +++++++++++++++++-- 1 file changed, 43 insertions(+), 3 deletions(-) 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 c485fc4..218594b 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 초기화 후 재요청 (무한 순환)") From 4cef082f4ea711ecf534fccfaa5d6ba60fdf4667 Mon Sep 17 00:00:00 2001 From: junhyukkkk Date: Mon, 8 Jun 2026 01:53:01 +0900 Subject: [PATCH 06/16] =?UTF-8?q?feat:=20=EB=AC=B4=EB=A3=8C=ED=88=AC?= =?UTF-8?q?=ED=91=9C=20=ED=96=89=EB=8F=99=20=EB=A1=9C=EA=B7=B8=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20(free=5Fvotes=5Fchecked,=20free=5Flimit=5Fexceeded)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../exception/GlobalExceptionHandler.java | 41 +++++++++++++++++++ .../adapter/web/GuestFreeVoteController.java | 22 ++++++---- 2 files changed, 56 insertions(+), 7 deletions(-) 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 fbc38b0..0f28740 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/vote/adapter/web/GuestFreeVoteController.java b/src/main/java/com/ject/vs/vote/adapter/web/GuestFreeVoteController.java index 3db7afe..f3e61e0 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; } } From bed3a8f6fd54a2de3480beeeae3215d4e796c242 Mon Sep 17 00:00:00 2001 From: junhyukkkk Date: Mon, 8 Jun 2026 01:53:08 +0900 Subject: [PATCH 07/16] =?UTF-8?q?feat:=20=EC=B1=84=ED=8C=85=20=ED=96=89?= =?UTF-8?q?=EB=8F=99=20=EB=A1=9C=EA=B7=B8=20=EC=B6=94=EA=B0=80=20(unread?= =?UTF-8?q?=5Ftotal=5Fcount=20=ED=95=A9=EC=82=B0,=20is=5Ffirst=5Fmessage?= =?UTF-8?q?=20=ED=8C=90=EB=8B=A8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../event/ChatMessageEventListener.java | 1 + .../vs/chat/adapter/web/ChatController.java | 61 ++++++++++++++++--- .../com/ject/vs/chat/port/ChatService.java | 9 ++- .../vs/chat/port/in/dto/MessageResult.java | 5 +- 4 files changed, 65 insertions(+), 11 deletions(-) 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 b1fb690..f1e6551 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 bc89169..6544e2c 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 d031be3..25005e5 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 3de948b..23d3136 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 ) {} From 68f521abcba85184905d2aae3d7554e2fd24ccd0 Mon Sep 17 00:00:00 2001 From: junhyukkkk Date: Mon, 8 Jun 2026 01:53:13 +0900 Subject: [PATCH 08/16] =?UTF-8?q?feat:=20=EC=95=8C=EB=A6=BC/=ED=91=B8?= =?UTF-8?q?=EC=8B=9C=20=ED=96=89=EB=8F=99=20=EB=A1=9C=EA=B7=B8=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20(notification=5Flist=5Fviewed/opened,=20push=5Ftoke?= =?UTF-8?q?n=5Fregistered)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../adapter/web/DeviceController.java | 6 +++++ .../adapter/web/NotificationController.java | 25 +++++++++++++++++-- .../port/NotificationCommandService.java | 4 ++- .../port/in/NotificationCommandUseCase.java | 10 +++++++- 4 files changed, 41 insertions(+), 4 deletions(-) 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 ac4836a..3c8775f 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 15c0104..3217adf 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 bb7db02..5b7b647 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 bf221f5..e4b3932 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); From 8fff07817892937e2f3e70252c31e1e71988fd05 Mon Sep 17 00:00:00 2001 From: junhyukkkk Date: Mon, 8 Jun 2026 01:53:17 +0900 Subject: [PATCH 09/16] =?UTF-8?q?feat:=20=EC=86=8C=EC=85=9C=20=EB=A1=9C?= =?UTF-8?q?=EA=B7=B8=EC=9D=B8=20=EA=B0=80=EC=9E=85=20=ED=96=89=EB=8F=99=20?= =?UTF-8?q?=EB=A1=9C=EA=B7=B8=20=EC=B6=94=EA=B0=80=20(signup=5Fcompleted)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../vs/config/OAuth2LoginSuccessHandler.java | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/main/java/com/ject/vs/config/OAuth2LoginSuccessHandler.java b/src/main/java/com/ject/vs/config/OAuth2LoginSuccessHandler.java index 047d1b0..8b93729 100644 --- a/src/main/java/com/ject/vs/config/OAuth2LoginSuccessHandler.java +++ b/src/main/java/com/ject/vs/config/OAuth2LoginSuccessHandler.java @@ -1,5 +1,7 @@ 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; @@ -12,6 +14,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 +30,7 @@ public class OAuth2LoginSuccessHandler extends SimpleUrlAuthenticationSuccessHan private final OAuth2Properties oauth2Properties; private final JwtProperties jwtProperties; private final CookieProperties cookieProperties; + private final AnalyticsEventLogger analytics; @Override public void onAuthenticationSuccess(HttpServletRequest request, @@ -43,6 +47,10 @@ public void onAuthenticationSuccess(HttpServletRequest request, String targetUrl = determineTargetUrl(loginResponse.getUserStatus()); + analytics.log(AnalyticsEvent.of("signup_completed") + .userId(loginResponse.getUserId()) + .put("method", resolveMethod(authentication))); + log.info("=== OAuth2 Login Success ==="); log.info("email: {}", email); log.info("userStatus: {}", loginResponse.getUserStatus()); @@ -83,6 +91,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(); From 8e9cb34f0ebb3386bd90821081dcdcdbdbc15240 Mon Sep 17 00:00:00 2001 From: junhyukkkk Date: Mon, 8 Jun 2026 01:53:27 +0900 Subject: [PATCH 10/16] =?UTF-8?q?test:=20=ED=96=89=EB=8F=99=20=EB=A1=9C?= =?UTF-8?q?=EA=B7=B8=20=EB=8F=84=EC=9E=85=EC=97=90=20=EB=94=B0=EB=A5=B8=20?= =?UTF-8?q?=EC=BB=A8=ED=8A=B8=EB=A1=A4=EB=9F=AC=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EB=B3=B4=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/ject/vs/chat/adapter/web/ChatControllerTest.java | 6 +++++- .../adapter/web/AdminPushControllerTest.java | 4 ++++ .../adapter/web/NotificationControllerTest.java | 7 +++++++ .../com/ject/vs/user/adapter/web/UserControllerTest.java | 4 ++++ .../vs/vote/adapter/web/GuestFreeVoteControllerTest.java | 2 ++ .../vs/vote/adapter/web/ImmersiveVoteControllerTest.java | 2 ++ .../com/ject/vs/vote/adapter/web/VoteControllerTest.java | 4 +++- .../vs/vote/adapter/web/VoteEmojiControllerTest.java | 9 ++++++--- .../vs/vote/adapter/web/VoteResultControllerTest.java | 2 ++ 9 files changed, 35 insertions(+), 5 deletions(-) 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 0fca2b5..5950b1f 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 25fec80..e24e68a 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 b0f4414..12f8246 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 168d081..23df765 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 f6b17b6..a7cd25c 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 f195971..bdb2343 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 5d33d6e..fce686d 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 3427c8d..fe9792a 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 0a39632..e9c1802 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()); From 7a30f4de26f84615294a329a25350c0e20ea2b10 Mon Sep 17 00:00:00 2001 From: junhyukkkk Date: Mon, 8 Jun 2026 01:54:02 +0900 Subject: [PATCH 11/16] =?UTF-8?q?feat:=20=ED=96=89=EB=8F=99=20=EB=A1=9C?= =?UTF-8?q?=EA=B7=B8(analytics=20event)=20=EB=A1=9C=EA=B9=85=20=EC=9D=B8?= =?UTF-8?q?=ED=94=84=EB=9D=BC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../vs/analytics/AnalyticsEventLogger.java | 110 ++++++++++++++++++ 1 file changed, 110 insertions(+) create mode 100644 src/main/java/com/ject/vs/analytics/AnalyticsEventLogger.java 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 0000000..a86bee4 --- /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"; + } +} From acb74c50f0556fef235e377a3236ca3938af95d4 Mon Sep 17 00:00:00 2001 From: junhyukkkk Date: Tue, 9 Jun 2026 23:55:58 +0900 Subject: [PATCH 12/16] =?UTF-8?q?feat:=20UTM=20=EA=B0=80=EC=9E=85=20?= =?UTF-8?q?=EC=B6=9C=EC=B2=98=20=EC=A0=80=EC=9E=A5=20=EC=8A=A4=ED=82=A4?= =?UTF-8?q?=EB=A7=88/=EB=8F=84=EB=A9=94=EC=9D=B8=20=EC=B6=94=EA=B0=80=20(u?= =?UTF-8?q?sers=20=EC=BB=AC=EB=9F=BC,=20UtmAttribution)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/ject/vs/user/domain/User.java | 20 +++++++++++++ .../ject/vs/user/domain/UtmAttribution.java | 30 +++++++++++++++++++ .../migration/V14__add_user_signup_source.sql | 8 +++++ 3 files changed, 58 insertions(+) create mode 100644 src/main/java/com/ject/vs/user/domain/UtmAttribution.java create mode 100644 src/main/resources/db/migration/V14__add_user_signup_source.sql 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 35a6585..4e192c3 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 0000000..edd9dc6 --- /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/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 0000000..b997a2e --- /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); From a17caf59914c423f7113905c0598ef41dcb77c7d Mon Sep 17 00:00:00 2001 From: junhyukkkk Date: Tue, 9 Jun 2026 23:56:06 +0900 Subject: [PATCH 13/16] =?UTF-8?q?feat:=20=EC=8B=A0=EA=B7=9C=20=EA=B0=80?= =?UTF-8?q?=EC=9E=85=20=EC=8B=9C=EC=97=90=EB=A7=8C=20UTM=20=EC=B6=9C?= =?UTF-8?q?=EC=B2=98=20=EA=B8=B0=EB=A1=9D=20(findOrCreate/socialLogin=20?= =?UTF-8?q?=EC=98=A4=EB=B2=84=EB=A1=9C=EB=93=9C)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/com/ject/vs/auth/port/AuthService.java | 7 ++++++- src/main/java/com/ject/vs/user/port/UserService.java | 11 ++++++++++- 2 files changed, 16 insertions(+), 2 deletions(-) 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 0d263d5..d8d40cc 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/user/port/UserService.java b/src/main/java/com/ject/vs/user/port/UserService.java index a7e8fd8..561fdfe 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) { From bac945969e739c3bf986eed5ccbc8fdfc1f1028b Mon Sep 17 00:00:00 2001 From: junhyukkkk Date: Tue, 9 Jun 2026 23:56:12 +0900 Subject: [PATCH 14/16] =?UTF-8?q?feat:=20UTM=20first-touch=20=EC=BF=A0?= =?UTF-8?q?=ED=82=A4=EC=99=80=20/api/track/visit=20=EC=97=94=EB=93=9C?= =?UTF-8?q?=ED=8F=AC=EC=9D=B8=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ject/vs/analytics/TrackingController.java | 40 +++++++ .../com/ject/vs/config/SecurityPaths.java | 3 +- .../java/com/ject/vs/config/UtmCookie.java | 110 ++++++++++++++++++ 3 files changed, 152 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/ject/vs/analytics/TrackingController.java create mode 100644 src/main/java/com/ject/vs/config/UtmCookie.java 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 0000000..5cc50d6 --- /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/config/SecurityPaths.java b/src/main/java/com/ject/vs/config/SecurityPaths.java index 4763e17..e122e81 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 0000000..3784f7d --- /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); + } +} From cba2bbe2ab2e803c6b72cd445af782097803609d Mon Sep 17 00:00:00 2001 From: junhyukkkk Date: Tue, 9 Jun 2026 23:56:16 +0900 Subject: [PATCH 15/16] =?UTF-8?q?feat:=20=EC=86=8C=EC=85=9C=20=EB=A1=9C?= =?UTF-8?q?=EA=B7=B8=EC=9D=B8=20=EA=B0=80=EC=9E=85=EC=97=90=20UTM=20?= =?UTF-8?q?=EC=B6=9C=EC=B2=98=20=EC=97=B0=EA=B2=B0=20(=EC=BF=A0=ED=82=A4?= =?UTF-8?q?=20=EC=86=8C=EB=B9=84=20=ED=9B=84=20=EB=A7=8C=EB=A3=8C)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../vs/config/OAuth2LoginSuccessHandler.java | 20 ++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/ject/vs/config/OAuth2LoginSuccessHandler.java b/src/main/java/com/ject/vs/config/OAuth2LoginSuccessHandler.java index 8b93729..f76a958 100644 --- a/src/main/java/com/ject/vs/config/OAuth2LoginSuccessHandler.java +++ b/src/main/java/com/ject/vs/config/OAuth2LoginSuccessHandler.java @@ -6,6 +6,7 @@ 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; @@ -31,6 +32,7 @@ public class OAuth2LoginSuccessHandler extends SimpleUrlAuthenticationSuccessHan private final JwtProperties jwtProperties; private final CookieProperties cookieProperties; private final AnalyticsEventLogger analytics; + private final UtmCookie utmCookie; @Override public void onAuthenticationSuccess(HttpServletRequest request, @@ -41,15 +43,27 @@ 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()); - analytics.log(AnalyticsEvent.of("signup_completed") + AnalyticsEvent event = AnalyticsEvent.of("signup_completed") .userId(loginResponse.getUserId()) - .put("method", resolveMethod(authentication))); + .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); From 38d9a41309ff42e9239d8a6eb85275b62ea2d774 Mon Sep 17 00:00:00 2001 From: junhyukkkk Date: Wed, 10 Jun 2026 14:55:27 +0900 Subject: [PATCH 16/16] =?UTF-8?q?feat:=20VoteDuration=EC=97=90=20HOURS=5F7?= =?UTF-8?q?2(72=EC=8B=9C=EA=B0=84)=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/com/ject/vs/vote/domain/VoteDuration.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 cf5477c..2dede70 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;