랜딩페이지의 유입 채널, 페이지 방문 수, 이메일 전환율을 수집하고 분석하는 백엔드 API 서비스
- Kotlin 2.3.0
- Spring Boot 4.0.2
- JPA + PostgreSQL
- Valkey/Redis (트래킹 버퍼)
- Thymeleaf (관리자 페이지)
docker compose up -d./gradlew bootRun| 변수 | 설명 | 기본값 |
|---|---|---|
DB_URL |
PostgreSQL 접속 URL | jdbc:postgresql://localhost:5432/uxlog |
DB_USERNAME |
DB 사용자명 | postgres |
DB_PASSWORD |
DB 비밀번호 | postgres |
ADMIN_USERNAME |
관리자 계정 | admin |
ADMIN_PASSWORD |
관리자 비밀번호 | admin |
REDIS_HOST |
Redis/Valkey 호스트 | localhost |
REDIS_PORT |
Redis/Valkey 포트 | 6379 |
REDIS_PASSWORD |
Redis/Valkey 비밀번호 | (없음) |
/api/track,/api/email: 인증 없이 접근 가능 (랜딩페이지에서 호출)/admin/**,/api/admin/**: 로그인 필요
기본 관리자 계정: admin / admin
운영 환경에서는 반드시 ADMIN_USERNAME, ADMIN_PASSWORD 환경 변수를 설정하세요.
랜딩페이지 로드 시 호출하여 방문을 기록합니다.
GET /api/track
Query Parameters:
| 파라미터 | 필수 | 설명 |
|---|---|---|
projectId |
O | 프로젝트 ID |
channel |
O | 유입 채널 (thread, instagram, twitter 등) |
postNumber |
X | 게시물 번호 |
visitorId |
X | 방문자 고유 식별자 (쿠키 등에서 생성) |
Note:
visitorId가 제공되지 않으면 IP + User-Agent 조합으로 고유 방문자를 식별합니다.
Example:
curl "http://localhost:8080/api/track?projectId=1&channel=thread&postNumber=42&visitorId=abc-123-def"Response: 204 No Content
내부 동작:
트래킹 API는 성능 향상을 위해 Valkey/Redis 버퍼를 사용합니다.
- API 호출 시 데이터를 Valkey List에 LPUSH (즉시 응답, ~1ms)
- 백그라운드 스케줄러가 5초마다 버퍼에서 데이터를 꺼내 PostgreSQL에 배치 INSERT
- Valkey 연결 실패 시 자동으로 직접 DB INSERT로 폴백
[API 요청] → [Valkey LPUSH] → [배치 스케줄러] → [PostgreSQL]
↓ (실패 시)
[직접 DB INSERT]
Note: 버퍼링으로 인해 통계 반영에 최대 5초의 지연이 발생할 수 있습니다.
사용자가 이메일을 입력했을 때 호출합니다.
POST /api/email
Request Body:
{
"projectId": 1,
"email": "user@example.com",
"channel": "thread",
"postNumber": "42"
}| 필드 | 필수 | 설명 |
|---|---|---|
projectId |
O | 프로젝트 ID |
email |
O | 이메일 주소 |
channel |
X | 유입 채널 |
postNumber |
X | 게시물 번호 |
Example:
curl -X POST "http://localhost:8080/api/email" \
-H "Content-Type: application/json" \
-d '{"projectId": 1, "email": "user@example.com", "channel": "thread"}'Response:
// 신규 등록 (201 Created)
{"success": true, "message": "Subscribed"}
// 이미 등록됨 (200 OK)
{"success": true, "message": "Already subscribed"}
// 에러 (400 Bad Request)
{"success": false, "message": "Invalid email format"}제약사항:
- 이메일 형식 검증 (유효한 이메일만 허용)
- 빈 문자열 불가
- 프로젝트별 동일 이메일 중복 등록 불가
- 존재하지 않는 프로젝트 ID는 에러
GET /api/projects/{projectId}/waiting-count
랜딩페이지에서 사용할 실시간 대기자 수를 조회합니다. 대기자 수 = 초기 대기자 수(waitingOffset) + 이메일 구독자 수
| 파라미터 | 타입 | 필수 | 설명 |
|---|---|---|---|
| projectId | Long | O | 프로젝트 ID |
{
"waitingCount": 85
}GET /api/admin/projects
Response:
[
{
"id": 1,
"name": "test1",
"description": "테스트 프로젝트",
"waitingOffset": 78,
"createdAt": "2024-01-01T00:00:00"
}
]POST /api/admin/projects
Request Body:
{
"name": "my-project",
"description": "프로젝트 설명",
"waitingOffset": 78
}| 필드 | 필수 | 설명 |
|---|---|---|
name |
O | 프로젝트명 |
description |
X | 프로젝트 설명 |
waitingOffset |
X | 초기 대기자 수 (기본값: 0) |
Response (201 Created):
{
"id": 1,
"name": "my-project",
"description": "프로젝트 설명",
"waitingOffset": 78,
"createdAt": "2024-01-01T00:00:00"
}PATCH /api/admin/projects/{id}/waiting-offset
프로젝트의 초기 대기자 수(waitingOffset)를 수정합니다.
Request Body:
{
"waitingOffset": 78
}Response (200 OK):
{
"id": 1,
"name": "프로젝트명",
"waitingOffset": 78
}GET /api/admin/projects/{id}/stats
Response:
{
"projectId": 1,
"projectName": "test1",
"totalPageViews": 150,
"totalUniqueVisitors": 120,
"totalEmails": 12,
"conversionRate": 10.0,
"channelStats": [
{
"channel": "thread",
"pageViews": 100,
"uniqueVisitors": 80,
"emails": 8,
"conversionRate": 10.0
},
{
"channel": "instagram",
"pageViews": 50,
"uniqueVisitors": 40,
"emails": 4,
"conversionRate": 10.0
}
]
}Note:
conversionRate는 고유 방문자(UV) 대비 이메일 전환율입니다.
전체 요약 + 일별 통계 + 포스트별 통계를 한번에 조회합니다.
GET /api/admin/projects/{id}/stats/detailed?days=30
| 파라미터 | 필수 | 기본값 | 설명 |
|---|---|---|---|
days |
X | 30 | 조회할 일수 |
Response:
{
"summary": { /* ProjectStatistics */ },
"dailyStats": [ /* DailyStatistics[] */ ],
"postStats": [ /* PostStatistics[] */ ]
}GET /api/admin/projects/{id}/stats/daily?days=30
Response:
[
{
"date": "2024-01-15",
"pageViews": 45,
"uniqueVisitors": 38,
"emails": 3,
"conversionRate": 7.89
},
{
"date": "2024-01-14",
"pageViews": 52,
"uniqueVisitors": 41,
"emails": 5,
"conversionRate": 12.19
}
]GET /api/admin/projects/{id}/stats/posts
Response:
[
{
"postNumber": "42",
"pageViews": 100,
"uniqueVisitors": 85,
"emails": 10,
"conversionRate": 11.76
},
{
"postNumber": "123",
"pageViews": 50,
"uniqueVisitors": 45,
"emails": 2,
"conversionRate": 4.44
}
]GET /api/admin/projects/{id}/stats/daily-posts?days=30
Response:
[
{
"date": "2024-01-15",
"postNumber": "42",
"pageViews": 25,
"uniqueVisitors": 20,
"emails": 2,
"conversionRate": 10.0
}
]GET /api/admin/projects/{id}/emails
Response:
[
{
"id": 1,
"email": "user@example.com",
"channel": "thread",
"postNumber": "42",
"createdAt": "2024-01-01T00:00:00"
}
]GET /api/admin/projects/{id}/emails/export
랜딩페이지 URL에 query parameter를 포함하여 유입 채널을 추적합니다.
랜딩페이지 URL 예시:
https://your-landing.com?channel=thread&postNumber=42
https://your-landing.com?channel=instagram&postNumber=123
이 query parameter들을 읽어서 트래킹 API에 전달합니다.
<script>
const PROJECT_ID = 1; // 프로젝트 ID
const API_BASE = 'https://your-uxlog-server.com';
// 방문자 ID 생성/조회 (쿠키에 저장)
function getVisitorId() {
let id = document.cookie.match(/visitorId=([^;]+)/)?.[1];
if (!id) {
id = crypto.randomUUID();
document.cookie = `visitorId=${id}; max-age=31536000; path=/`; // 1년 유지
}
return id;
}
// URL에서 query parameter 읽기
const params = new URLSearchParams(window.location.search);
const channel = params.get('channel') || 'direct';
const postNumber = params.get('postNumber') || '';
const visitorId = getVisitorId();
// 페이지 로드 시 방문 기록
fetch(`${API_BASE}/api/track?projectId=${PROJECT_ID}&channel=${channel}&postNumber=${postNumber}&visitorId=${visitorId}`);
// 이메일 폼 제출
document.getElementById('emailForm').addEventListener('submit', async (e) => {
e.preventDefault();
const email = document.getElementById('email').value;
await fetch(`${API_BASE}/api/email`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
projectId: PROJECT_ID,
email: email,
channel: channel,
postNumber: postNumber
})
});
alert('구독 완료!');
});
</script>활용 예시:
- 스레드 게시물:
https://your-landing.com?channel=thread&postNumber=1 - 인스타그램:
https://your-landing.com?channel=instagram&postNumber=abc123 - 트위터:
https://your-landing.com?channel=twitter - 직접 유입:
https://your-landing.com(channel이 없으면 'direct'로 기록)
| 경로 | 설명 |
|---|---|
/admin |
대시보드 (전체 요약) |
/admin/projects |
프로젝트 목록 |
/admin/projects/{id} |
프로젝트 상세 통계 |
/admin/projects/{id}/emails |
이메일 목록 + CSV 내보내기 |