Skip to content

Commit 289d5f4

Browse files
authored
Merge pull request #244 from JECT-Study/feature/analytics-ga4-forwarding
Feature/analytics ga4 forwarding
2 parents 024f680 + 1423218 commit 289d5f4

8 files changed

Lines changed: 310 additions & 3 deletions

File tree

.github/workflows/release.yml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -124,11 +124,14 @@ jobs:
124124
AWS_S3_SECRET_KEY: ${{ secrets.AWS_S3_SECRET_KEY }}
125125
GEMINI_PROJECT_ID: ${{ secrets.GEMINI_PROJECT_ID }}
126126
GEMINI_ENABLED: ${{ secrets.GEMINI_ENABLED }}
127+
GA_ENABLED: ${{ secrets.GA_ENABLED }}
128+
GA_MEASUREMENT_ID: ${{ secrets.GA_MEASUREMENT_ID }}
129+
GA_API_SECRET: ${{ secrets.GA_API_SECRET }}
127130
with:
128131
host: ${{ secrets.EC2_HOST }}
129132
username: ${{ secrets.EC2_USERNAME }}
130133
key: ${{ secrets.EC2_SSH_KEY }}
131-
envs: DATABASE_HOST,DATABASE_USERNAME,DATABASE_PASSWORD,APP_JWT_SECRET,APP_JWT_ACCESS_TOKEN_EXPIRATION_SECONDS,APP_JWT_REFRESH_TOKEN_EXPIRATION_SECONDS,APP_OAUTH2_REDIRECT_SUCCESS_URL,APP_OAUTH2_EXTRA_INFO_URL,GOOGLE_OAUTH_CLIENT_ID,GOOGLE_OAUTH_CLIENT_SECRET,FIREBASE_SERVICE_ACCOUNT_JSON,AWS_S3_BUCKET,AWS_S3_REGION,AWS_S3_ACCESS_KEY,AWS_S3_SECRET_KEY,GEMINI_PROJECT_ID,GEMINI_ENABLED
134+
envs: DATABASE_HOST,DATABASE_USERNAME,DATABASE_PASSWORD,APP_JWT_SECRET,APP_JWT_ACCESS_TOKEN_EXPIRATION_SECONDS,APP_JWT_REFRESH_TOKEN_EXPIRATION_SECONDS,APP_OAUTH2_REDIRECT_SUCCESS_URL,APP_OAUTH2_EXTRA_INFO_URL,GOOGLE_OAUTH_CLIENT_ID,GOOGLE_OAUTH_CLIENT_SECRET,FIREBASE_SERVICE_ACCOUNT_JSON,AWS_S3_BUCKET,AWS_S3_REGION,AWS_S3_ACCESS_KEY,AWS_S3_SECRET_KEY,GEMINI_PROJECT_ID,GEMINI_ENABLED,GA_ENABLED,GA_MEASUREMENT_ID,GA_API_SECRET
132135
script: |
133136
mkdir -p /home/ubuntu/app/secrets
134137
echo "$FIREBASE_SERVICE_ACCOUNT_JSON" > /home/ubuntu/app/secrets/firebase-service-account.json

scripts/deploy.sh

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,18 @@ if [ -n "${GEMINI_PROJECT_ID:-}" ]; then
157157
log "Gemini 설정 추가됨"
158158
fi
159159

160+
# GA4 Measurement Protocol 설정 (선택적)
161+
# 측정 ID가 있을 때만 전달. 없으면 앱은 no-op으로 동작(RDB 적재는 그대로).
162+
if [ -n "${GA_MEASUREMENT_ID:-}" ]; then
163+
DOCKER_OPTS+=(
164+
-e GA_ENABLED
165+
-e GA_MEASUREMENT_ID
166+
-e GA_API_SECRET
167+
-e GA_ENDPOINT
168+
)
169+
log "GA4 설정 추가됨"
170+
fi
171+
160172
docker run "${DOCKER_OPTS[@]}" "$IMAGE" >/dev/null
161173

162174
# 헬스체크 (docker inspect로 healthy 상태 확인)

src/main/java/com/ject/vs/analytics/AnalyticsEventLogger.java

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ public class AnalyticsEventLogger {
4444
private final ObjectMapper objectMapper;
4545
private final Clock clock;
4646
private final AnalyticsEventRepository analyticsEventRepository;
47+
private final GoogleAnalyticsClient googleAnalytics;
4748

4849
public void log(AnalyticsEvent event) {
4950
try {
@@ -53,6 +54,8 @@ public void log(AnalyticsEvent event) {
5354
String anonymousId = event.anonymousIdOverridden()
5455
? event.anonymousId()
5556
: resolveAnonymousId(request);
57+
String platform = resolvePlatform(request);
58+
boolean member = userId != null;
5659

5760
// 이벤트별 가변 속성은 JSON 문자열로 직렬화해 properties 컬럼에 담는다.
5861
String properties = event.properties().isEmpty()
@@ -63,10 +66,14 @@ public void log(AnalyticsEvent event) {
6366
event.name(),
6467
userId,
6568
anonymousId,
66-
userId != null,
67-
resolvePlatform(request),
69+
member,
70+
platform,
6871
Instant.now(clock),
6972
properties));
73+
74+
// 같은 이벤트를 GA4로도 전송(PM·디자이너 대시보드용). 비동기 fire-and-forget이라
75+
// 실패/지연이 위 RDB 적재나 요청 처리에 영향을 주지 않는다. 미설정이면 no-op.
76+
googleAnalytics.send(event.name(), userId, anonymousId, member, platform, event.properties());
7077
} catch (Exception e) {
7178
// 로깅 실패가 요청 처리에 영향을 주지 않도록 흡수
7279
AnalyticsEventLogger.log.warn("analytics event logging failed for '{}': {}", event.name(), e.getMessage());
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
package com.ject.vs.analytics;
2+
3+
import org.slf4j.Logger;
4+
import org.slf4j.LoggerFactory;
5+
import org.springframework.http.MediaType;
6+
import org.springframework.http.client.SimpleClientHttpRequestFactory;
7+
import org.springframework.scheduling.annotation.Async;
8+
import org.springframework.stereotype.Component;
9+
import org.springframework.web.client.RestClient;
10+
import org.springframework.web.util.UriComponentsBuilder;
11+
12+
import java.time.Duration;
13+
import java.util.LinkedHashMap;
14+
import java.util.List;
15+
import java.util.Map;
16+
import java.util.UUID;
17+
18+
/**
19+
* 행동 로그를 GA4(Google Analytics 4)로도 전송하는 어댑터.
20+
*
21+
* <p>RDB(analytics_events) 적재와 별개로, PM·디자이너가 GA4 대시보드에서 같은 이벤트를
22+
* 실시간으로 볼 수 있게 한다. 서버 사이드라 SDK가 아니라
23+
* <a href="https://developers.google.com/analytics/devguides/collection/protocol/ga4">GA4 Measurement Protocol</a>
24+
* (HTTP POST)로 전송한다.
25+
*
26+
* <p>설계 원칙(기존 {@link AnalyticsEventLogger}와 동일):
27+
* <ul>
28+
* <li><b>비즈니스에 영향 없음</b> — 모든 예외를 삼키고, 미설정 시 no-op.</li>
29+
* <li><b>요청 지연 없음</b> — 외부 HTTP라 {@code @Async}로 분리(fire-and-forget).
30+
* 호출 스레드에서 필요한 값(clientId/userId/params)을 모두 인자로 받으므로
31+
* 비동기 스레드에 요청 컨텍스트가 없어도 안전하다.</li>
32+
* </ul>
33+
*/
34+
@Component
35+
public class GoogleAnalyticsClient {
36+
37+
/** 전송 실패 경고 등 내부 진단용 로거(파일 어펜더는 "analytics" 로거명 공유). */
38+
private static final Logger log = LoggerFactory.getLogger("analytics");
39+
40+
/** GA4가 사용자를 '참여(engaged)'로 집계하도록 넣는 최소 참여 시간(ms). */
41+
private static final int ENGAGEMENT_TIME_MSEC = 1;
42+
43+
/** GA4 이벤트 파라미터 값 문자열 최대 길이(MP 제약: 100자). */
44+
private static final int MAX_VALUE_LENGTH = 100;
45+
46+
private final GoogleAnalyticsProperties properties;
47+
private final RestClient restClient;
48+
49+
public GoogleAnalyticsClient(GoogleAnalyticsProperties properties, RestClient.Builder builder) {
50+
this.properties = properties;
51+
// 외부망 지연이 비동기 스레드를 오래 점유하지 않도록 짧은 타임아웃을 둔다.
52+
SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory();
53+
factory.setConnectTimeout((int) Duration.ofSeconds(2).toMillis());
54+
factory.setReadTimeout((int) Duration.ofSeconds(2).toMillis());
55+
this.restClient = builder.requestFactory(factory).build();
56+
}
57+
58+
@Async("analyticsExecutor")
59+
public void send(String eventName, Long userId, String anonymousId, boolean member,
60+
String platform, Map<String, Object> properties) {
61+
if (!this.properties.isConfigured()) {
62+
return;
63+
}
64+
try {
65+
Map<String, Object> payload = buildPayload(eventName, userId, anonymousId, member, platform, properties);
66+
67+
String url = UriComponentsBuilder.fromUriString(this.properties.endpoint())
68+
.queryParam("measurement_id", this.properties.measurementId())
69+
.queryParam("api_secret", this.properties.apiSecret())
70+
.build()
71+
.toUriString();
72+
73+
restClient.post()
74+
.uri(url)
75+
.contentType(MediaType.APPLICATION_JSON)
76+
.body(payload)
77+
.retrieve()
78+
.toBodilessEntity();
79+
} catch (Exception e) {
80+
log.warn("GA4 forwarding failed for '{}': {}", eventName, e.getMessage());
81+
}
82+
}
83+
84+
/**
85+
* GA4 Measurement Protocol 요청 본문을 만든다. (전송 없이 순수 변환 — 테스트 가능)
86+
*
87+
* <pre>{@code
88+
* { "client_id": "...", "user_id": "42", "events": [ { "name": "...", "params": { ... } } ] }
89+
* }</pre>
90+
*/
91+
Map<String, Object> buildPayload(String eventName, Long userId, String anonymousId, boolean member,
92+
String platform, Map<String, Object> properties) {
93+
Map<String, Object> event = new LinkedHashMap<>();
94+
event.put("name", eventName);
95+
event.put("params", buildParams(member, platform, properties));
96+
97+
Map<String, Object> payload = new LinkedHashMap<>();
98+
// client_id는 필수. 비회원 쿠키(anonymous_id)를 우선 사용해 가입 전후 흐름을 한 사용자로 잇는다.
99+
payload.put("client_id", resolveClientId(anonymousId, userId));
100+
if (userId != null) {
101+
payload.put("user_id", String.valueOf(userId));
102+
}
103+
payload.put("events", List.of(event));
104+
return payload;
105+
}
106+
107+
private Map<String, Object> buildParams(boolean member, String platform, Map<String, Object> properties) {
108+
Map<String, Object> params = new LinkedHashMap<>();
109+
params.put("engagement_time_msec", ENGAGEMENT_TIME_MSEC);
110+
params.put("is_member", member);
111+
if (platform != null) {
112+
params.put("platform", platform);
113+
}
114+
if (properties != null) {
115+
properties.forEach((key, value) -> {
116+
Object sanitized = sanitize(value);
117+
if (sanitized != null) {
118+
params.put(key, sanitized);
119+
}
120+
});
121+
}
122+
return params;
123+
}
124+
125+
/** GA4 파라미터 값 규칙에 맞춘다: null은 제외, 숫자는 그대로, 그 외는 문자열(최대 100자)로. */
126+
private Object sanitize(Object value) {
127+
if (value == null) {
128+
return null;
129+
}
130+
if (value instanceof Number) {
131+
return value;
132+
}
133+
String text = value.toString();
134+
return text.length() > MAX_VALUE_LENGTH ? text.substring(0, MAX_VALUE_LENGTH) : text;
135+
}
136+
137+
/** 익명 쿠키 → 회원 id → 랜덤 UUID 순으로 안정적인 client_id를 고른다. */
138+
private String resolveClientId(String anonymousId, Long userId) {
139+
if (anonymousId != null && !anonymousId.isBlank()) {
140+
return anonymousId;
141+
}
142+
if (userId != null) {
143+
return "uid." + userId;
144+
}
145+
return UUID.randomUUID().toString();
146+
}
147+
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
package com.ject.vs.analytics;
2+
3+
import org.springframework.boot.context.properties.ConfigurationProperties;
4+
5+
/**
6+
* GA4 Measurement Protocol 전송 설정.
7+
*
8+
* <p>서버 사이드에서 GA4로 이벤트를 보내려면 구글 애널리틱스 콘솔에서 발급한
9+
* <b>Measurement ID</b>(웹 데이터 스트림, {@code G-XXXXXXXX})와
10+
* <b>API secret</b>(Admin → Data Streams → Measurement Protocol API secrets)이 필요하다.
11+
* 구글 계정 아이디/비밀번호는 서버가 직접 쓰지 않는다(콘솔 로그인용일 뿐).
12+
*
13+
* <p>미설정(또는 {@code enabled=false})이면 {@link GoogleAnalyticsClient}가 no-op이 되어
14+
* 로컬·테스트 환경에서 외부 호출이 발생하지 않는다. (Gemini 토글과 동일한 철학)
15+
*/
16+
@ConfigurationProperties(prefix = "google.analytics")
17+
public record GoogleAnalyticsProperties(
18+
boolean enabled,
19+
String measurementId,
20+
String apiSecret,
21+
String endpoint
22+
) {
23+
public GoogleAnalyticsProperties {
24+
if (endpoint == null || endpoint.isBlank()) {
25+
endpoint = "https://www.google-analytics.com/mp/collect";
26+
}
27+
}
28+
29+
/** enabled이면서 자격증명이 모두 채워졌을 때만 실제 전송한다. */
30+
public boolean isConfigured() {
31+
return enabled
32+
&& measurementId != null && !measurementId.isBlank()
33+
&& apiSecret != null && !apiSecret.isBlank();
34+
}
35+
}

src/main/java/com/ject/vs/config/AsyncConfig.java

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
77

88
import java.util.concurrent.Executor;
9+
import java.util.concurrent.ThreadPoolExecutor;
910

1011
@Configuration
1112
@EnableAsync
@@ -43,4 +44,17 @@ public Executor notificationExecutor() {
4344
executor.initialize();
4445
return executor;
4546
}
47+
48+
/** GA4 Measurement Protocol 전송용. 행동 로그는 양이 많아 큐를 넉넉히 두고, 넘치면 버린다(분석 데이터라 유실 허용). */
49+
@Bean("analyticsExecutor")
50+
public Executor analyticsExecutor() {
51+
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
52+
executor.setCorePoolSize(1);
53+
executor.setMaxPoolSize(4);
54+
executor.setQueueCapacity(1000);
55+
executor.setThreadNamePrefix("ga-");
56+
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.DiscardPolicy());
57+
executor.initialize();
58+
return executor;
59+
}
4660
}

src/main/resources/application.yml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,16 @@ aws:
1515
access-key: ${AWS_S3_ACCESS_KEY:}
1616
secret-key: ${AWS_S3_SECRET_KEY:}
1717

18+
# GA4 Measurement Protocol (행동 로그를 GA4 대시보드로도 전송 — PM/디자이너용)
19+
# enabled=true + Measurement ID(G-XXXX) + API secret이 모두 있어야 실제 전송. 없으면 no-op(RDB 적재는 그대로).
20+
# 자격증명은 GA4 콘솔에서 발급: Admin → Data Streams(웹 스트림 = Measurement ID) / Measurement Protocol API secrets(= API secret)
21+
google:
22+
analytics:
23+
enabled: ${GA_ENABLED:false}
24+
measurement-id: ${GA_MEASUREMENT_ID:}
25+
api-secret: ${GA_API_SECRET:}
26+
endpoint: ${GA_ENDPOINT:https://www.google-analytics.com/mp/collect}
27+
1828
gemini:
1929
project-id: ${GEMINI_PROJECT_ID:}
2030
# 서울(asia-northeast3)은 Gemini 모델 미지원 → us-central1 사용. GEMINI_LOCATION으로 override 가능
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
package com.ject.vs.analytics;
2+
3+
import org.junit.jupiter.api.Test;
4+
import org.springframework.web.client.RestClient;
5+
6+
import java.util.LinkedHashMap;
7+
import java.util.List;
8+
import java.util.Map;
9+
10+
import static org.assertj.core.api.Assertions.assertThat;
11+
12+
/**
13+
* GA4 Measurement Protocol 페이로드 변환 규칙 검증(전송 없이 순수 변환만).
14+
*/
15+
class GoogleAnalyticsClientTest {
16+
17+
private GoogleAnalyticsClient client(boolean enabled) {
18+
GoogleAnalyticsProperties props =
19+
new GoogleAnalyticsProperties(enabled, "G-TEST", "secret", null);
20+
return new GoogleAnalyticsClient(props, RestClient.builder());
21+
}
22+
23+
@Test
24+
void 비회원은_anonymous_id를_client_id로_쓰고_user_id는_빠진다() {
25+
Map<String, Object> payload = client(true).buildPayload(
26+
"landing_visited", null, "anon-uuid", false, "web", Map.of());
27+
28+
assertThat(payload.get("client_id")).isEqualTo("anon-uuid");
29+
assertThat(payload).doesNotContainKey("user_id");
30+
}
31+
32+
@Test
33+
void 회원은_user_id가_문자열로_들어가고_익명쿠키가_없으면_uid로_client_id를_만든다() {
34+
Map<String, Object> payload = client(true).buildPayload(
35+
"signup_completed", 42L, null, true, "web", Map.of());
36+
37+
assertThat(payload.get("user_id")).isEqualTo("42");
38+
assertThat(payload.get("client_id")).isEqualTo("uid.42");
39+
}
40+
41+
@Test
42+
@SuppressWarnings("unchecked")
43+
void 공통_파라미터와_이벤트_속성이_params에_합쳐진다() {
44+
Map<String, Object> props = new LinkedHashMap<>();
45+
props.put("vote_id", 123);
46+
props.put("vote_status", "ONGOING");
47+
48+
Map<String, Object> payload = client(true).buildPayload(
49+
"vote_detail_viewed", 42L, "anon", true, "ios", props);
50+
51+
Map<String, Object> event = ((List<Map<String, Object>>) payload.get("events")).get(0);
52+
assertThat(event.get("name")).isEqualTo("vote_detail_viewed");
53+
54+
Map<String, Object> params = (Map<String, Object>) event.get("params");
55+
assertThat(params)
56+
.containsEntry("engagement_time_msec", 1)
57+
.containsEntry("is_member", true)
58+
.containsEntry("platform", "ios")
59+
.containsEntry("vote_id", 123)
60+
.containsEntry("vote_status", "ONGOING");
61+
}
62+
63+
@Test
64+
@SuppressWarnings("unchecked")
65+
void null_속성은_제외되고_긴_문자열은_100자로_잘린다() {
66+
Map<String, Object> props = new LinkedHashMap<>();
67+
props.put("nullable", null);
68+
props.put("long_text", "x".repeat(150));
69+
70+
Map<String, Object> payload = client(true).buildPayload(
71+
"some_event", 1L, "anon", true, "web", props);
72+
73+
Map<String, Object> event = ((List<Map<String, Object>>) payload.get("events")).get(0);
74+
Map<String, Object> params = (Map<String, Object>) event.get("params");
75+
76+
assertThat(params).doesNotContainKey("nullable");
77+
assertThat((String) params.get("long_text")).hasSize(100);
78+
}
79+
}

0 commit comments

Comments
 (0)