Skip to content

Commit 9a7fa9a

Browse files
authored
Merge pull request #235 from JECT-Study/feature/analytics-events-db
feat: 행동 로그를 analytics_events 테이블에 적재 + 유입 전환율 추적
2 parents b34abb7 + 1e7d4e6 commit 9a7fa9a

7 files changed

Lines changed: 191 additions & 20 deletions

File tree

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
package com.ject.vs.analytics;
2+
3+
import com.ject.vs.support.BaseIntegrationTest;
4+
import org.junit.jupiter.api.Test;
5+
import org.springframework.beans.factory.annotation.Autowired;
6+
7+
import java.util.List;
8+
9+
import static org.assertj.core.api.Assertions.assertThat;
10+
11+
/**
12+
* 행동 로그가 실제 PostgreSQL(analytics_events 테이블)에 한 행으로 적재되는지 검증한다.
13+
*
14+
* <p>V15 마이그레이션 적용 → 엔티티 매핑 → INSERT 까지 실제 DB로 확인한다.
15+
* 적재 실패는 {@link AnalyticsEventLogger}가 예외를 삼키므로, 행이 안 생기는 것으로 드러난다.
16+
*/
17+
class AnalyticsEventLoggerIntegrationTest extends BaseIntegrationTest {
18+
19+
@Autowired
20+
private AnalyticsEventLogger analytics;
21+
22+
@Autowired
23+
private AnalyticsEventRepository analyticsEventRepository;
24+
25+
@Test
26+
void 비회원_이벤트는_properties_JSON과_함께_한_행으로_저장된다() {
27+
analytics.log(AnalyticsEvent.of("landing_visited")
28+
.put("utm_source", "instagram")
29+
.put("utm_campaign", "tangsuyuk"));
30+
31+
List<AnalyticsEventRecord> rows = analyticsEventRepository.findAll();
32+
assertThat(rows).hasSize(1);
33+
34+
AnalyticsEventRecord row = rows.get(0);
35+
assertThat(row.getEvent()).isEqualTo("landing_visited");
36+
assertThat(row.isMember()).isFalse();
37+
assertThat(row.getUserId()).isNull();
38+
assertThat(row.getOccurredAt()).isEqualTo(FIXED_NOW);
39+
assertThat(row.getProperties())
40+
.contains("\"utm_source\":\"instagram\"")
41+
.contains("\"utm_campaign\":\"tangsuyuk\"");
42+
}
43+
44+
@Test
45+
void userId가_지정되면_회원으로_저장된다() {
46+
analytics.log(AnalyticsEvent.of("signup_completed")
47+
.userId(1024L)
48+
.put("method", "kakao"));
49+
50+
AnalyticsEventRecord row = analyticsEventRepository.findAll().get(0);
51+
assertThat(row.getUserId()).isEqualTo(1024L);
52+
assertThat(row.isMember()).isTrue();
53+
assertThat(row.getProperties()).contains("\"method\":\"kakao\"");
54+
}
55+
56+
@Test
57+
void 속성이_없으면_properties는_null로_저장된다() {
58+
analytics.log(AnalyticsEvent.of("simple_event"));
59+
60+
AnalyticsEventRecord row = analyticsEventRepository.findAll().get(0);
61+
assertThat(row.getEvent()).isEqualTo("simple_event");
62+
assertThat(row.getProperties()).isNull();
63+
}
64+
}

src/integrationTest/java/com/ject/vs/support/BaseIntegrationTest.java

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,6 @@
99
import org.springframework.test.context.bean.override.mockito.MockitoBean;
1010
import org.springframework.transaction.annotation.Transactional;
1111
import org.testcontainers.containers.PostgreSQLContainer;
12-
import org.testcontainers.junit.jupiter.Container;
13-
import org.testcontainers.junit.jupiter.Testcontainers;
1412

1513
import java.time.Clock;
1614
import java.time.Instant;
@@ -22,23 +20,32 @@
2220
* Integration Test의 공통 설정을 담당하는 추상 클래스.
2321
*
2422
* 통합 테스트는 Testcontainers 기반 실제 PostgreSQL 위에서 실행됩니다.
23+
*
24+
* <p>컨테이너는 JUnit({@code @Testcontainers}) 라이프사이클에 맡기지 않고
25+
* 정적 초기화 블록에서 한 번만 시작하는 싱글톤 패턴을 사용합니다.
26+
* Spring이 ApplicationContext를 캐싱해 여러 테스트 클래스에 재사용하는데,
27+
* 클래스 단위로 컨테이너를 내리면 캐시된 컨텍스트가 이미 중지된 컨테이너를
28+
* 가리켜 {@code ConnectException}이 발생하기 때문입니다. 한 번 시작한 컨테이너는
29+
* JVM 종료 시 Ryuk가 정리합니다.
2530
*/
2631
@SpringBootTest
2732
@ActiveProfiles("test")
2833
@Transactional
29-
@Testcontainers(disabledWithoutDocker = true)
3034
public abstract class BaseIntegrationTest {
3135

3236
protected static final Instant FIXED_NOW = Instant.parse("2025-06-01T12:00:00Z");
3337

34-
@Container
3538
@ServiceConnection
3639
static final PostgreSQLContainer<?> POSTGRES =
3740
new PostgreSQLContainer<>("postgres:16-alpine")
3841
.withDatabaseName("vs_integration_test")
3942
.withUsername("test")
4043
.withPassword("test");
4144

45+
static {
46+
POSTGRES.start();
47+
}
48+
4249
@Autowired
4350
protected EntityManager entityManager;
4451

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

Lines changed: 18 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -15,14 +15,13 @@
1515
import java.time.Clock;
1616
import java.time.Instant;
1717
import java.util.Arrays;
18-
import java.util.LinkedHashMap;
19-
import java.util.Map;
2018

2119
/**
22-
* 행동 로그(analytics event)를 구조화된 JSON줄로 남기는 컴포넌트.
20+
* 행동 로그(analytics event)를 RDB(analytics_events 테이블)에건씩 적재하는 컴포넌트.
2321
*
2422
* <p>공통 로그 변수(user_id / anonymous_id / is_member / platform / occurred_at)는
25-
* 현재 HTTP 요청 컨텍스트에서 자동으로 채워진다. 이벤트별 변수는 {@link AnalyticsEvent}로 전달한다.
23+
* 현재 HTTP 요청 컨텍스트에서 자동으로 채워지고, 이벤트별 가변 변수는 JSON 문자열로
24+
* 직렬화해 properties 컬럼에 담는다. 이벤트별 변수는 {@link AnalyticsEvent}로 전달한다.
2625
*
2726
* <pre>{@code
2827
* analytics.log(AnalyticsEvent.of("vote_detail_viewed")
@@ -31,19 +30,20 @@
3130
* }</pre>
3231
*
3332
* <p>유일한 진입점이 void {@code log(AnalyticsEvent)}이므로 테스트에서 목(mock)으로 주입해도
34-
* 부수효과 없이 동작한다. 로그 적재 실패가 비즈니스 로직에 영향을 주지 않도록 모든 예외를 삼킨다.
33+
* 부수효과 없이 동작한다. 적재 실패가 비즈니스 로직에 영향을 주지 않도록 모든 예외를 삼킨다.
3534
*/
3635
@Component
3736
@RequiredArgsConstructor
3837
public class AnalyticsEventLogger {
3938

40-
/** 별도 로거 이름으로 분리하여 수집/필터링이 쉽도록 한다. */
39+
/** 적재 실패 경고 등 내부 진단용 로거. */
4140
private static final Logger log = LoggerFactory.getLogger("analytics");
4241

4342
private static final String ANONYMOUS_COOKIE = "anonymous_id";
4443

4544
private final ObjectMapper objectMapper;
4645
private final Clock clock;
46+
private final AnalyticsEventRepository analyticsEventRepository;
4747

4848
public void log(AnalyticsEvent event) {
4949
try {
@@ -54,16 +54,19 @@ public void log(AnalyticsEvent event) {
5454
? event.anonymousId()
5555
: resolveAnonymousId(request);
5656

57-
Map<String, Object> payload = new LinkedHashMap<>();
58-
payload.put("event", event.name());
59-
payload.put("user_id", userId);
60-
payload.put("anonymous_id", anonymousId);
61-
payload.put("is_member", userId != null);
62-
payload.put("platform", resolvePlatform(request));
63-
payload.put("occurred_at", Instant.now(clock).toString());
64-
payload.putAll(event.properties());
57+
// 이벤트별 가변 속성은 JSON 문자열로 직렬화해 properties 컬럼에 담는다.
58+
String properties = event.properties().isEmpty()
59+
? null
60+
: objectMapper.writeValueAsString(event.properties());
6561

66-
AnalyticsEventLogger.log.info(objectMapper.writeValueAsString(payload));
62+
analyticsEventRepository.save(new AnalyticsEventRecord(
63+
event.name(),
64+
userId,
65+
anonymousId,
66+
userId != null,
67+
resolvePlatform(request),
68+
Instant.now(clock),
69+
properties));
6770
} catch (Exception e) {
6871
// 로깅 실패가 요청 처리에 영향을 주지 않도록 흡수
6972
AnalyticsEventLogger.log.warn("analytics event logging failed for '{}': {}", event.name(), e.getMessage());
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
package com.ject.vs.analytics;
2+
3+
import jakarta.persistence.Column;
4+
import jakarta.persistence.Entity;
5+
import jakarta.persistence.GeneratedValue;
6+
import jakarta.persistence.GenerationType;
7+
import jakarta.persistence.Id;
8+
import jakarta.persistence.Table;
9+
import lombok.AccessLevel;
10+
import lombok.Getter;
11+
import lombok.NoArgsConstructor;
12+
13+
import java.time.Instant;
14+
15+
/**
16+
* 행동 로그 한 건을 RDB(analytics_events)에 적재하기 위한 엔티티.
17+
*
18+
* <p>공통 필드는 컬럼으로, 이벤트별 가변 속성은 {@code properties}(JSON 문자열)에 담는다.
19+
* 마이그레이션 {@code V15__add_analytics_events.sql} 과 1:1로 대응한다.
20+
*/
21+
@Entity
22+
@Table(name = "analytics_events")
23+
@Getter
24+
@NoArgsConstructor(access = AccessLevel.PROTECTED)
25+
public class AnalyticsEventRecord {
26+
27+
@Id
28+
@GeneratedValue(strategy = GenerationType.IDENTITY)
29+
private Long id;
30+
31+
@Column(nullable = false, length = 100)
32+
private String event;
33+
34+
private Long userId;
35+
36+
@Column(length = 64)
37+
private String anonymousId;
38+
39+
@Column(name = "is_member", nullable = false)
40+
private boolean member;
41+
42+
@Column(length = 20)
43+
private String platform;
44+
45+
@Column(nullable = false)
46+
private Instant occurredAt;
47+
48+
/** 이벤트별 속성을 직렬화한 JSON 문자열. 속성이 없으면 null. */
49+
@Column(columnDefinition = "text")
50+
private String properties;
51+
52+
public AnalyticsEventRecord(String event, Long userId, String anonymousId,
53+
boolean member, String platform, Instant occurredAt, String properties) {
54+
this.event = event;
55+
this.userId = userId;
56+
this.anonymousId = anonymousId;
57+
this.member = member;
58+
this.platform = platform;
59+
this.occurredAt = occurredAt;
60+
this.properties = properties;
61+
}
62+
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
package com.ject.vs.analytics;
2+
3+
import org.springframework.data.jpa.repository.JpaRepository;
4+
5+
public interface AnalyticsEventRepository extends JpaRepository<AnalyticsEventRecord, Long> {
6+
}

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

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
public class TrackingController {
2525

2626
private final UtmCookie utmCookie;
27+
private final AnalyticsEventLogger analytics;
2728

2829
@GetMapping("/visit")
2930
public ResponseEntity<Void> visit(
@@ -34,7 +35,16 @@ public ResponseEntity<Void> visit(
3435
HttpServletRequest request,
3536
HttpServletResponse response) {
3637

37-
utmCookie.writeFirstTouch(request, response, UtmAttribution.of(source, medium, campaign, content));
38+
UtmAttribution utm = UtmAttribution.of(source, medium, campaign, content);
39+
utmCookie.writeFirstTouch(request, response, utm);
40+
41+
// 클릭(유입) 자체를 1건의 로그로 적재한다. signup_completed(가입)와 짝지어 전환율을 계산한다.
42+
analytics.log(AnalyticsEvent.of("landing_visited")
43+
.put("utm_source", utm.source())
44+
.put("utm_medium", utm.medium())
45+
.put("utm_campaign", utm.campaign())
46+
.put("utm_content", utm.content()));
47+
3848
return ResponseEntity.noContent().build();
3949
}
4050
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
-- 행동 로그(analytics event)를 RDB에 직접 적재하기 위한 append-only 테이블.
2+
-- 공통 필드(event/user_id/anonymous_id/is_member/platform/occurred_at)는 컬럼으로,
3+
-- 이벤트마다 달라지는 속성은 properties(JSON 문자열)에 담는다.
4+
-- user_id는 비회원이면 null이며, 로그 적재가 users 존재에 묶이지 않도록 FK를 두지 않는다.
5+
CREATE TABLE analytics_events (
6+
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
7+
event VARCHAR(100) NOT NULL,
8+
user_id BIGINT,
9+
anonymous_id VARCHAR(64),
10+
is_member BOOLEAN NOT NULL,
11+
platform VARCHAR(20),
12+
occurred_at TIMESTAMP NOT NULL,
13+
properties TEXT
14+
);
15+
16+
-- 이벤트별 기간 집계(event 카운트, 전환율 등)에 사용.
17+
CREATE INDEX idx_analytics_event_time ON analytics_events (event, occurred_at);
18+
-- 전체 기간 스캔용.
19+
CREATE INDEX idx_analytics_time ON analytics_events (occurred_at);

0 commit comments

Comments
 (0)