Google Analytics 대신 방문자 로그 수집 시스템 직접 구현하기
NestJS + PostgreSQL로 만든 가벼운 자체 방문자 로그 수집 시스템
개인 블로그에 방문자 분석이 필요했습니다.
누가 어떤 글을 읽는지, 어디서 유입되는지 정도만 알면 됐습니다. 근데 그 단순한 요구사항에 Google Analytics를 붙이기엔 뭔가 과했습니다.
결론부터 말하면, NestJS + PostgreSQL로 방문자 분석 시스템을 직접 만들었고, 실제로 데이터를 보면서 블로그 운영에 꽤 유용하게 쓰고 있습니다.
왜 직접 만들었는가
GA를 안 쓴 이유는 크게 세 가지입니다.
첫째, 내 데이터를 내가 관리하고 싶었습니다. 개인 블로그인데 방문자 데이터가 구글 서버에 있다는 게 좀 찜찜했습니다. 내 서버에 직접 쌓으면 원하는 형태로 자유롭게 조회할 수 있고, 데이터 보존 기간 제한도 없습니다.
둘째, GA는 무겁습니다. gtag.js만 해도 90KB가 넘습니다. 블로그 성능을 위해 번들 사이즈를 줄여놓고, 분석 스크립트가 그걸 까먹으면 의미가 없습니다.
셋째, 개인정보 이슈입니다. GA는 쿠키를 심고, 사용자를 추적합니다. GDPR 때문에 쿠키 동의 배너까지 달아야 합니다. 개인 블로그에서 그런 배너를 보여주고 싶지 않았습니다.
솔직히 말하면 "직접 만들면 재밌겠다"는 것도 있었습니다. 백엔드를 직접 운영하고 있으니까, 모듈 하나 추가하면 끝이었거든요.
전체 구조
프론트엔드에서 페이지 이동 시 POST /api/analytics/pageview를 호출하고, 서버에서 필터링 후 DB에 저장합니다. geolocation은 비동기로 별도 처리합니다.
프론트엔드 (페이지 진입)
└── POST /api/analytics/pageview { path, appName, referrer }
│
├── 봇 필터링 (User-Agent 검사)
├── 본인 IP 제외
├── DB 저장 (즉시)
└── Geolocation 조회 (비동기, DB 업데이트)
어드민 대시보드에서는 방문자 수, 인기 글, 유입 경로, 방문자 타임라인을 API로 조회합니다.
봇 필터링
페이지뷰를 기록하기 전에 봇을 걸러냅니다. User-Agent에 bot, crawl, spider 같은 패턴이 있으면 저장하지 않습니다.
const BOT_PATTERNS = [
/bot/i, /crawl/i, /spider/i,
/googlebot/i, /bingbot/i,
/headlesschrome/i, /puppeteer/i,
// ... 20여 개 패턴
];
function detectBot(userAgent?: string): boolean {
if (!userAgent) return true; // UA 없으면 봇으로 간주
return BOT_PATTERNS.some(pattern => pattern.test(userAgent));
}User-Agent가 아예 없는 경우도 봇으로 판단합니다. 일반적인 브라우저는 항상 UA를 보내기 때문입니다.
본인 IP 제외
블로그를 만들면서 제일 많이 접속하는 사람은 저 자신입니다. 이 트래픽이 섞이면 데이터가 의미 없어집니다.
환경변수로 제외할 IP를 설정합니다.
constructor(private readonly config: ConfigService) {
const raw = this.config.get<string>('ANALYTICS_EXCLUDE_IPS', '');
this.excludeIps = new Set(
raw.split(',').map(ip => ip.trim()).filter(Boolean),
);
}
async track(dto: TrackPageViewInput): Promise<void> {
if (detectBot(dto.userAgent)) return;
if (dto.ipAddress && this.excludeIps.has(dto.ipAddress)) return;
// DB 저장...
}.env에 ANALYTICS_EXCLUDE_IPS=1.2.3.4,5.6.7.8 형태로 넣으면 됩니다. 집이랑 카페 IP가 바뀌면 업데이트해주면 되는데, 고정 IP가 아니라 가끔 빠지는 경우도 있습니다. 이건 나중에 개선할 부분입니다.
비동기 Geolocation
방문자의 IP로 도시와 국가를 조회합니다. ip-api.com의 무료 API를 사용했습니다.
핵심은 응답을 블로킹하지 않는 것입니다. pageview 요청이 들어오면 일단 DB에 바로 저장하고, geolocation은 비동기로 조회해서 나중에 업데이트합니다.
// 먼저 DB에 저장 (즉시 응답)
const record = await this.prisma.pageView.create({
data: { path, appName, referrer, userAgent, ipAddress, isBot },
});
// geolocation은 비동기로 처리 (응답 블로킹 없음)
if (dto.ipAddress) {
this.geo.lookup(dto.ipAddress)
.then(geo => {
if (geo.city || geo.country) {
return this.prisma.pageView.update({
where: { id: record.id },
data: { city: geo.city, country: geo.country },
});
}
})
.catch(err => this.logger.warn(`Geo update failed: ${err}`));
}await를 걸지 않습니다. 프론트엔드는 204 No Content를 즉시 받고, geolocation 조회는 백그라운드에서 진행됩니다. 외부 API가 느리거나 실패해도 pageview 기록에는 영향이 없습니다.
레이트 리밋 큐
ip-api.com 무료 플랜은 분당 45건 제한이 있습니다. 초당으로 환산하면 약 1.33초에 1건입니다. 여러 요청이 동시에 들어오면 제한에 걸릴 수 있어서, 직렬 큐를 구현했습니다.
private lastRequestAt = 0;
private pending: Promise<GeoResult> = Promise.resolve({ city: null, country: null });
private enqueue(ip: string): Promise<GeoResult> {
const next = this.pending.then(() => this.throttledLookup(ip));
this.pending = next.catch(() => ({ city: null, country: null }));
return next;
}Promise 체이닝으로 구현했습니다. 새 요청이 들어오면 이전 Promise의 then에 연결합니다. 이전 요청이 끝나야 다음 요청이 실행되니까 자연스럽게 직렬화됩니다.
private async throttledLookup(ip: string): Promise<GeoResult> {
// 큐에서 기다리는 동안 다른 요청이 같은 IP를 이미 처리했을 수 있음
const recheck = this.cache.get(ip);
if (recheck && recheck.expiry > Date.now()) return recheck.result;
// 최소 1.4초 간격 보장
const elapsed = Date.now() - this.lastRequestAt;
if (elapsed < MIN_REQUEST_INTERVAL) {
await new Promise(r => setTimeout(r, MIN_REQUEST_INTERVAL - elapsed));
}
this.lastRequestAt = Date.now();
// ip-api.com 호출...
}큐에서 대기하는 동안 같은 IP가 이미 처리됐을 수 있으니 캐시를 재확인합니다. 캐시는 인메모리 Map으로 24시간 TTL을 두고, 최대 5000건까지 저장합니다.
Referrer 정규화
유입 경로를 분석하려면 referrer URL을 정리해야 합니다. https://www.google.com/search?q=chahyunwoo 같은 raw URL을 google.com으로 추출하고, 카테고리(search/social/direct)로 분류합니다.
const SEARCH_DOMAINS = new Set([
'google.com', 'bing.com', 'naver.com', 'daum.net', // ...
]);
const SOCIAL_DOMAINS = new Set([
'twitter.com', 'x.com', 'github.com', 'linkedin.com', // ...
]);
function extractDomain(url: string): string {
try {
const hostname = new URL(url).hostname;
return hostname.replace(/^www\./, '');
} catch {
return url;
}
}
function categorizeReferrer(domain: string): string {
if (SEARCH_DOMAINS.has(domain)) return 'search';
if (SOCIAL_DOMAINS.has(domain)) return 'social';
return 'other';
}referrer가 없으면 direct로 분류합니다. 북마크나 URL 직접 입력의 경우입니다. 어드민 대시보드에서는 유입 경로별 비율과 도메인별 카운트를 한눈에 볼 수 있습니다.
방문자 타임라인
어드민에서 가장 유용한 기능입니다. IP별로 방문 경로를 타임라인으로 보여줍니다. "이 방문자가 어떤 글을 어떤 순서로 읽었는지"를 추적할 수 있습니다.
async getVisitorsTimeline(days?: number, appName?: string) {
const views = await this.prisma.pageView.findMany({
where: { createdAt: { gte: since }, ...this.ipFilter },
select: {
ipAddress: true, path: true, referrer: true,
city: true, country: true, isBot: true, createdAt: true,
},
orderBy: { createdAt: 'desc' },
});
// IP별로 그룹핑
const grouped = new Map<string, { city, country, visits: [] }>();
// ...
}IP는 마스킹해서 반환합니다. 123.456.xxx.xxx 형태로 마지막 두 옥텟을 가립니다. 개인 블로그라 접속자가 많지 않은데, 그래도 방문자 IP를 어드민에서 그대로 노출하는 건 좋지 않습니다.
private maskIp(ip: string): string {
if (ip === 'unknown') return 'unknown';
const parts = ip.split('.');
return `${parts[0]}.${parts[1]}.xxx.xxx`;
}실제 데이터로 확인한 결과
배포하고 며칠간 데이터를 모아봤습니다.
총 169건의 pageview 중 131건이 본인 IP였습니다. ANALYTICS_EXCLUDE_IPS를 설정하기 전이라 제 접속이 전부 포함된 겁니다. 실제 외부 방문자는 38건이었습니다.
확실히 흥미로운 데이터가 보였습니다. geolocation으로 확인해보니 연세대학교 근처에서 접속한 방문자가 있었고, 구글 검색으로 유입된 케이스도 몇 건 있었습니다. GA에서는 이런 걸 대시보드에서 찾아 들어가야 하는데, 직접 만드니까 타임라인 API 한 번 호출하면 바로 보입니다.
데이터가 적긴 하지만, 어떤 글이 검색에 걸리는지, 어떤 경로로 유입되는지 파악하기엔 충분합니다.
기존 데이터 Backfill
ANALYTICS_EXCLUDE_IPS를 설정하기 전에 쌓인 데이터에는 geolocation 정보가 없었습니다. 이미 저장된 레코드에도 city/country를 채워야 했습니다.
별도 스크립트를 만들어서 city IS NULL AND ip_address IS NOT NULL인 레코드를 조회한 뒤, 레이트 리밋 큐를 통해 순차적으로 geolocation을 채웠습니다. ip-api.com 무료 제한(45/min) 때문에 169건 처리하는 데 약 4분 걸렸습니다.
프론트엔드 연동
프론트에서는 페이지 이동 시 한 줄만 호출하면 됩니다.
export async function trackPageView(path: string, appName: string) {
await api.post('analytics/pageview', {
json: { path, appName, referrer: document.referrer || undefined },
}).catch(() => {}); // 분석 실패가 사용자 경험에 영향주면 안 됨
}.catch(() => {})로 에러를 무시합니다. 분석 요청이 실패해도 사용자가 페이지를 못 보면 안 되니까요.
정리
좋은 점
- GA 스크립트 없이 가볍습니다. 프론트에서 POST 한 번이 전부입니다.
- 데이터가 내 DB에 있으니 원하는 형태로 자유롭게 쿼리할 수 있습니다.
- 방문자 타임라인이 생각보다 유용합니다. 어떤 글에서 어떤 글로 이동하는지 패턴이 보입니다.
- Promise 체이닝 큐로 외부 API 제한을 깔끔하게 처리했습니다.
- 쿠키를 사용하지 않아서 GDPR 걱정이 없습니다.
아쉬운 점
- 고정 IP가 아니면 본인 제외가 완벽하지 않습니다. 인증 기반 필터링을 추가하면 해결될 것 같습니다.
- ip-api.com 무료 플랜 의존이라 대량 트래픽에는 맞지 않습니다. 개인 블로그 규모에서는 충분합니다.
- 인메모리 캐시라 서버 재시작 시 geolocation 캐시가 날아갑니다. 어차피 DB에 저장되어 있으니 같은 IP가 다시 오면 조회만 다시 하면 됩니다.
- UA 기반 봇 필터링은 완벽하지 않습니다. 정교한 봇은 정상 UA를 쓰기도 합니다.
GA가 나쁜 도구는 아닙니다. 대규모 서비스라면 GA의 퍼널 분석이나 이벤트 추적이 훨씬 강력합니다. 근데 개인 블로그에서 "누가 어떤 글을 읽었는지" 정도만 알고 싶다면, 직접 만드는 게 더 가볍고 재밌습니다.