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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
143 changes: 143 additions & 0 deletions src/main/java/com/ject/vs/analytics/AmplitudeClient.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
package com.ject.vs.analytics;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.MediaType;
import org.springframework.http.client.SimpleClientHttpRequestFactory;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestClient;

import java.time.Duration;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;

/**
* 행동 로그를 Amplitude로도 전송하는 어댑터.
*
* <p>RDB(analytics_events) 적재·GA4 전송과 별개로, PM·디자이너가 Amplitude 대시보드에서
* 같은 이벤트(퍼널/리텐션 등)를 볼 수 있게 한다. 서버 사이드라 SDK가 아니라
* <a href="https://amplitude.com/docs/apis/analytics/http-v2">HTTP V2 API</a>(HTTP POST)로 전송한다.
*
* <p>설계 원칙({@link GoogleAnalyticsClient}와 동일):
* <ul>
* <li><b>비즈니스에 영향 없음</b> — 모든 예외를 삼키고, 미설정 시 no-op.</li>
* <li><b>요청 지연 없음</b> — 외부 HTTP라 {@code @Async}로 분리(fire-and-forget).
* 호출 스레드에서 필요한 값을 모두 인자로 받으므로 비동기 스레드에 요청 컨텍스트가 없어도 안전하다.</li>
* </ul>
*/
@Component
public class AmplitudeClient {

/** 전송 실패 경고 등 내부 진단용 로거(파일 어펜더는 "analytics" 로거명 공유). */
private static final Logger log = LoggerFactory.getLogger("analytics");

/** Amplitude는 user_id / device_id가 최소 5자 이상이어야 이벤트를 수락한다. */
private static final int MIN_ID_LENGTH = 5;

private final AmplitudeProperties properties;
private final RestClient restClient;

public AmplitudeClient(AmplitudeProperties properties, RestClient.Builder builder) {
this.properties = properties;
// 외부망 지연이 비동기 스레드를 오래 점유하지 않도록 짧은 타임아웃을 둔다.
SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory();
factory.setConnectTimeout((int) Duration.ofSeconds(2).toMillis());
factory.setReadTimeout((int) Duration.ofSeconds(2).toMillis());
this.restClient = builder.requestFactory(factory).build();
}

@Async("analyticsExecutor")
public void send(String eventName, Long userId, String anonymousId, boolean member,
String platform, Map<String, Object> properties) {
if (!this.properties.isConfigured()) {
return;
}
try {
Map<String, Object> payload = buildPayload(eventName, userId, anonymousId, member, platform, properties);

restClient.post()
.uri(this.properties.endpoint())
.contentType(MediaType.APPLICATION_JSON)
.body(payload)
.retrieve()
.toBodilessEntity();
} catch (Exception e) {
log.warn("Amplitude forwarding failed for '{}': {}", eventName, e.getMessage());
}
}

/**
* Amplitude HTTP V2 요청 본문을 만든다. (전송 없이 순수 변환 — 테스트 가능)
*
* <pre>{@code
* { "api_key": "...", "events": [ { "event_type": "...", "user_id": "...", "device_id": "...", ... } ] }
* }</pre>
*/
Map<String, Object> buildPayload(String eventName, Long userId, String anonymousId, boolean member,
String platform, Map<String, Object> properties) {
Map<String, Object> event = new LinkedHashMap<>();
event.put("event_type", eventName);

// 회원이면 안정적인 user_id를, 비회원 쿠키가 있으면 device_id를 같이 실어
// 가입 전후 흐름을 한 사용자로 잇는다(Amplitude의 identity 병합).
if (userId != null) {
event.put("user_id", "uid." + userId);
}
String deviceId = resolveDeviceId(anonymousId, userId);
if (deviceId != null) {
event.put("device_id", deviceId);
}
if (platform != null) {
event.put("platform", platform);
}
event.put("event_properties", buildEventProperties(member, properties));

Map<String, Object> payload = new LinkedHashMap<>();
payload.put("api_key", this.properties.apiKey());
payload.put("events", List.of(event));
return payload;
}

private Map<String, Object> buildEventProperties(boolean member, Map<String, Object> properties) {
Map<String, Object> result = new LinkedHashMap<>();
result.put("is_member", member);
if (properties != null) {
properties.forEach((key, value) -> {
Object sanitized = sanitize(value);
if (sanitized != null) {
result.put(key, sanitized);
}
});
}
return result;
}

/** null은 제외, 숫자/불리언은 그대로, 그 외는 문자열로. */
private Object sanitize(Object value) {
if (value == null) {
return null;
}
if (value instanceof Number || value instanceof Boolean) {
return value;
}
return value.toString();
}

/**
* device_id를 정한다: 익명 쿠키(5자 이상) → 회원 id 파생값 → 랜덤 UUID 순.
* Amplitude는 user_id/device_id 중 하나가 5자 이상이어야 하므로, 둘 다 비는 경우를 막는다.
*/
private String resolveDeviceId(String anonymousId, Long userId) {
if (anonymousId != null && anonymousId.length() >= MIN_ID_LENGTH) {
return anonymousId;
}
if (userId != null) {
// 회원은 user_id("uid.N")가 이미 5자 이상이므로 device_id는 굳이 채우지 않는다.
return null;
}
return UUID.randomUUID().toString();
}
}
32 changes: 32 additions & 0 deletions src/main/java/com/ject/vs/analytics/AmplitudeProperties.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package com.ject.vs.analytics;

import org.springframework.boot.context.properties.ConfigurationProperties;

/**
* Amplitude HTTP V2 API 전송 설정.
*
* <p>서버 사이드에서 Amplitude로 이벤트를 보내려면 Amplitude 프로젝트의
* <b>API Key</b>(Settings → Projects → 해당 프로젝트의 API Key)만 있으면 된다.
* GA4와 달리 별도 secret은 필요 없다.
*
* <p>미설정(또는 {@code enabled=false})이면 {@link AmplitudeClient}가 no-op이 되어
* 로컬·테스트 환경에서 외부 호출이 발생하지 않는다. (GA4 토글과 동일한 철학)
*/
@ConfigurationProperties(prefix = "amplitude")
public record AmplitudeProperties(
boolean enabled,
String apiKey,
String endpoint
) {
public AmplitudeProperties {
if (endpoint == null || endpoint.isBlank()) {
// EU 데이터 거주가 필요하면 https://api.eu.amplitude.com/2/httpapi 로 override
endpoint = "https://api2.amplitude.com/2/httpapi";
}
}

/** enabled이면서 API Key가 채워졌을 때만 실제 전송한다. */
public boolean isConfigured() {
return enabled && apiKey != null && !apiKey.isBlank();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ public class AnalyticsEventLogger {
private final Clock clock;
private final AnalyticsEventRepository analyticsEventRepository;
private final GoogleAnalyticsClient googleAnalytics;
private final AmplitudeClient amplitude;

public void log(AnalyticsEvent event) {
try {
Expand All @@ -71,9 +72,10 @@ public void log(AnalyticsEvent event) {
Instant.now(clock),
properties));

// 같은 이벤트를 GA4로도 전송(PM·디자이너 대시보드용). 비동기 fire-and-forget이라
// 실패/지연이 위 RDB 적재나 요청 처리에 영향을 주지 않는다. 미설정이면 no-op.
// 같은 이벤트를 GA4·Amplitude로도 전송(PM·디자이너 대시보드용). 비동기 fire-and-forget이라
// 실패/지연이 위 RDB 적재나 요청 처리에 영향을 주지 않는다. 미설정이면 각각 no-op.
googleAnalytics.send(event.name(), userId, anonymousId, member, platform, event.properties());
amplitude.send(event.name(), userId, anonymousId, member, platform, event.properties());
} catch (Exception e) {
// 로깅 실패가 요청 처리에 영향을 주지 않도록 흡수
AnalyticsEventLogger.log.warn("analytics event logging failed for '{}': {}", event.name(), e.getMessage());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -106,14 +106,15 @@ public ShareLinkResponse getShareLink(@PathVariable Long voteId) {
return response;
}

@Operation(summary = "랜덤 다음 투표 조회", description = "excludeIds를 제외한 진행 중인 투표를 랜덤으로 조회합니다. 모든 투표 소진 시 빈 배열 반환 → 클라이언트에서 excludeIds 초기화 후 재요청 (무한 순환)")
@Operation(summary = "랜덤 다음 투표 조회", description = "excludeIds를 제외한 진행 중인 투표를 랜덤으로 조회합니다. 모든 투표 소진 시 빈 배열 반환 → 클라이언트에서 excludeIds 초기화 후 재요청 (무한 순환). startVoteId를 지정하면 진행 중인 해당 투표가 맨 앞에 배치되고 나머지가 랜덤으로 채워집니다.")
@PostMapping("/next")
public ImmersiveNextResponse getNextRandom(
@RequestBody @Valid ImmersiveNextRequest request,
@AuthenticationPrincipal Long userId,
@Parameter(hidden = true) @AnonymousId String anonymousId) {
return ImmersiveNextResponse.from(
immersiveVoteQueryUseCase.getNextRandom(request.excludeIds(), request.size(), userId, anonymousId)
immersiveVoteQueryUseCase.getNextRandom(
request.excludeIds(), request.startVoteId(), request.size(), userId, anonymousId)
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
public record ImmersiveNextRequest(
List<Long> excludeIds,

Long startVoteId,

@Min(1)
@Max(50)
Integer size
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -90,20 +90,37 @@ public ImmersiveLiveResult getLive(Long voteId) {
}

@Override
public ImmersiveNextResult getNextRandom(List<Long> excludeIds, int size, Long userId, String anonymousId) {
public ImmersiveNextResult getNextRandom(List<Long> excludeIds, Long startVoteId, int size, Long userId, String anonymousId) {
Instant now = Instant.now(clock);
PageRequest pageable = PageRequest.of(0, size);

Slice<Vote> slice;
if (excludeIds == null || excludeIds.isEmpty()) {
slice = voteRepository.findRandom(now, pageable);
} else {
slice = voteRepository.findRandomExcluding(now, excludeIds, pageable);
List<Long> exclude = excludeIds == null ? new ArrayList<>() : new ArrayList<>(excludeIds);

// startVoteId가 지정되면 진행 중인 해당 투표를 맨 앞에 배치하고 나머지를 랜덤으로 채운다.
Vote startVote = null;
if (startVoteId != null && !exclude.contains(startVoteId)) {
Vote candidate = voteRepository.findById(startVoteId).orElse(null);
if (candidate != null && !candidate.isEnded(clock)) {
startVote = candidate;
exclude.add(startVoteId);
}
}

List<ImmersiveFeedItem> items = slice.getContent().stream()
.map(v -> toFeedItem(v, userId, anonymousId))
.toList();
int remaining = startVote != null ? size - 1 : size;

List<ImmersiveFeedItem> items = new ArrayList<>();
if (startVote != null) {
items.add(toFeedItem(startVote, userId, anonymousId));
}

if (remaining > 0) {
PageRequest pageable = PageRequest.of(0, remaining);
Slice<Vote> slice = exclude.isEmpty()
? voteRepository.findRandom(now, pageable)
: voteRepository.findRandomExcluding(now, exclude, pageable);
slice.getContent().stream()
.map(v -> toFeedItem(v, userId, anonymousId))
.forEach(items::add);
}

return new ImmersiveNextResult(items);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,9 @@ public interface ImmersiveVoteQueryUseCase {

/**
* 랜덤 다음 투표 조회 (excludeIds 제외, 무한 순환)
* startVoteId가 지정되면 진행 중인 해당 투표를 맨 앞에 배치하고 나머지를 랜덤으로 채운다.
*/
ImmersiveNextResult getNextRandom(List<Long> excludeIds, int size, Long userId, String anonymousId);
ImmersiveNextResult getNextRandom(List<Long> excludeIds, Long startVoteId, int size, Long userId, String anonymousId);

record ImmersiveFeedResult(List<ImmersiveFeedItem> items, Long nextCursor, boolean hasNext) {
}
Expand Down
8 changes: 8 additions & 0 deletions src/main/resources/application.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,14 @@ google:
api-secret: ${GA_API_SECRET:}
endpoint: ${GA_ENDPOINT:https://www.google-analytics.com/mp/collect}

# Amplitude HTTP V2 API (행동 로그를 Amplitude 대시보드로도 전송 — PM/디자이너용)
# enabled=true + API Key가 있어야 실제 전송. 없으면 no-op(RDB 적재는 그대로).
# API Key는 Amplitude 콘솔에서 발급: Settings → Projects → 해당 프로젝트의 API Key
amplitude:
enabled: ${AMPLITUDE_ENABLED:false}
api-key: ${AMPLITUDE_API_KEY:}
endpoint: ${AMPLITUDE_ENDPOINT:https://api2.amplitude.com/2/httpapi}

gemini:
project-id: ${GEMINI_PROJECT_ID:}
# 서울(asia-northeast3)은 Gemini 모델 미지원 → us-central1 사용. GEMINI_LOCATION으로 override 가능
Expand Down
Loading
Loading