훌륭한 Nest.js 블로그 만들기: 방문자 분석
Min-jun Kim
Dev Intern · Leapcell

직전 튜토리얼인 전체 텍스트 검색 기능 통합에서는 블로그에 전체 텍스트 검색 기능을 통합하여 좋은 콘텐츠를 더 쉽게 찾을 수 있도록 했습니다.
이제 블로그의 기능이 더욱 풍부해지고 콘텐츠가 늘어남에 따라 자연스럽게 새로운 질문이 떠오릅니다. 독자들에게 가장 인기 있는 글은 무엇일까요?
독자의 관심사를 이해하면 더 높은 품질의 콘텐츠를 만드는 데 도움이 될 수 있습니다.
따라서 이번 튜토리얼에서는 블로그에 기본적이지만 매우 중요한 기능인 방문자 추적을 추가할 것입니다. 각 게시물이 읽힌 횟수를 기록하고 페이지에 조회수를 표시합니다.
Google Analytics와 같은 타사 서비스를 사용하는 것을 고려할 수 있습니다. 하지만 직접 백엔드 기반 추적 시스템을 구축하면 더 많은 데이터를 직접 관리하고 수집하려는 데이터를 사용자 정의할 수 있습니다.
시작해 봅시다:
1단계: 조회 기록 데이터 모델 생성
데이터베이스 테이블 생성
아래 SQL 문을 PostgreSQL 데이터베이스에서 실행하여 page_view
테이블을 생성합니다. 이 테이블은 향후 심층 분석을 위해 각 조회 시간, 해당 게시물 및 일부 방문자 정보(IP 주소 및 User-Agent와 같은)를 기록합니다.
CREATE TABLE "page_view" ( "id" UUID PRIMARY KEY DEFAULT gen_random_uuid(), "createdAt" TIMESTAMPTZ NOT NULL DEFAULT NOW(), "postId" UUID REFERENCES "post"("id") ON DELETE CASCADE, "ipAddress" VARCHAR(45), "userAgent" TEXT );
ON DELETE CASCADE
는 게시물이 삭제될 때 관련 조회 기록도 자동으로 정리되도록 합니다.
Leapcell에서 데이터베이스를 생성한 경우,
그래픽 인터페이스를 사용하여 SQL 문을 쉽게 실행할 수 있습니다. 웹사이트에서 데이터베이스 관리 페이지로 이동하여 위의 문을 SQL 인터페이스에 붙여넣고 실행하기만 하면 됩니다.
PageView 엔티티 생성
다음으로 이 추적 기능을 위한 새 모듈을 만듭니다.
nest generate module tracking nest generate service tracking
src/tracking
디렉터리에 page-view.entity.ts
파일을 만듭니다.
// src/tracking/page-view.entity.ts import { Entity, PrimaryColumn, CreateDateColumn, ManyToOne, Column } from 'typeorm'; import { Post } from '../posts/post.entity'; @Entity() export class PageView { @PrimaryColumn({ type: 'uuid', default: () => 'gen_random_uuid()' }) id: string; @CreateDateColumn() createdAt: Date; @ManyToOne(() => Post, { onDelete: 'CASCADE' }) post: Post; @Column({ type: 'varchar', length: 45, nullable: true }) ipAddress: string; @Column({ type: 'text', nullable: true }) userAgent: string; }
2단계: 추적 서비스 구현
TrackingService
는 새 조회 기록, 조회수 쿼리 등 조회 기록과 관련된 모든 로직을 처리하는 책임자가 됩니다.
PageView 엔티티 등록
src/tracking/tracking.module.ts
를 열고 TypeOrmModule
을 등록하고 TrackingService
를 내보냅니다.
// src/tracking/tracking.module.ts import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { TrackingService } from './tracking.service'; import { PageView } from './page-view.entity'; @Module({ imports: [TypeOrmModule.forFeature([PageView])], providers: [TrackingService], exports: [TrackingService], }) export class TrackingModule {}
서비스 로직 작성
src/tracking/tracking.service.ts
를 수정하여 기록 및 쿼리 메서드를 추가합니다.
// src/tracking/tracking.service.ts import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { PageView } from './page-view.entity'; import { Post } from '../posts/post.entity'; @Injectable() export class TrackingService { constructor( @InjectRepository(PageView) private pageViewRepository: Repository<PageView> ) {} async recordView(post: Post, ipAddress: string, userAgent: string): Promise<void> { const newView = this.pageViewRepository.create({ post, ipAddress, userAgent, }); await this.pageViewRepository.save(newView); } async getCountByPostId(postId: string): Promise<number> { return this.pageViewRepository.count({ where: { post: { id: postId } }, }); } // 효율성을 위해 한 번에 여러 게시물에 대한 조회수 가져오기 async getCountsByPostIds(postIds: string[]): Promise<Record<string, number>> { if (postIds.length === 0) { return {}; } const counts = await this.pageViewRepository .createQueryBuilder('page_view') .select('page_view.postId', 'postId') .addSelect('COUNT(*)', 'count') .where('page_view.postId IN (:...postIds)', { postIds }) .groupBy('page_view.postId') .getRawMany(); const result: Record<string, number> = {}; counts.forEach((item) => { result[item.postId] = parseInt(item.count, 10); }); return result; } }
getCountsByPostIds
메서드는 TypeORM의QueryBuilder
를 사용하여 SQL을 직접 작성하므로 더 효율적인GROUP BY
쿼리를 사용할 수 있습니다. 이는 특히 홈페이지에서 많은 게시물의 조회수를 표시해야 할 때 각 게시물에 대해 별도의count
쿼리를 실행하는 것보다 훨씬 빠릅니다.
3단계: 게시물 페이지에 조회 기록 통합
다음으로 방문자가 게시물을 볼 때마다 TrackingService
의 recordView
메서드를 호출해야 합니다. 이를 위한 가장 적합한 위치는 블로그 게시물 콘텐츠를 가져오는 PostsController
의 post
메서드입니다.
먼저 src/posts/posts.module.ts
에 TrackingModule
을 가져옵니다.
// src/posts/posts.module.ts // ... import { TrackingModule } from '../tracking/tracking.module'; @Module({ imports: [TypeOrmModule.forFeature([Post]), CommentsModule, TrackingModule], // TrackingModule 가져오기 controllers: [PostsController], providers: [PostsService], }) export class PostsModule {}
그런 다음 PostsController
에 TrackingService
를 주입하고 호출합니다.
// src/posts/posts.controller.ts import { Controller, Get, Render, Param, Request } from '@nestjs/common'; import { PostsService } from './posts.service'; import { CommentsService } from '../comments/comments.service'; import { TrackingService } from '../tracking/tracking.service'; // 가져오기 import { marked } from 'marked'; @Controller('posts') export class PostsController { constructor( private readonly postsService: PostsService, private readonly commentsService: CommentsService, private readonly trackingService: TrackingService // 주입 ) {} // ... 다른 메서드 @Get(':id') @Render('post') async post(@Param('id') id: string, @Request() req) { const post = await this.postsService.findOne(id); const comments = await this.commentsService.findByPostId(id); const viewCount = await this.trackingService.getCountByPostId(id); if (post) { // 조회 기록 this.trackingService.recordView(post, req.ip, req.headers['user-agent']); post.content = marked.parse(post.content) as string; } return { post, user: req.session.user, comments, viewCount }; } }
참고: recordView
메서드에 await
를 사용하지 않았습니다. 이는 "fire-and-forget" 호출입니다. 조회수 기록이라는 부수적인 작업을 위해 응답을 지연시키고 싶지 않습니다. 기록이 성공 여부에 관계없이 독자에게 게시물 페이지가 최대한 빨리 반환되어야 합니다.
4단계: 프론트엔드에 조회수 표시
게시물 상세 페이지
이전 단계에서 viewCount
를 가져와 post.ejs
템플릿에 전달했습니다. 이제 템플릿에 표시하기만 하면 됩니다.
views/post.ejs
를 열고 게시물의 메타정보 영역에 조회수를 추가합니다.
<article class="post-detail"> <h1><%= post.title %></h1> <small> <%= new Date(post.createdAt).toLocaleDateString() %> | Views: <%= viewCount %> </small> <div class="post-content"><%- post.content %></div> </article>
블로그 홈페이지
홈페이지의 게시물 목록에도 조회수를 표시하려면 PostsService
와 PostsController
를 약간 수정해야 합니다.
src/posts/posts.service.ts
업데이트:
조회수가 포함된 게시물 목록을 가져오는 새 메서드를 만듭니다.
// src/posts/posts.service.ts @Injectable() export class PostsService { // ... constructor // Post와 조회수를 결합하는 인터페이스 public async findAllWithViewCount(trackingService: TrackingService): Promise<any[]> { const posts = await this.findAll(); const postIds = posts.map((post) => post.id); const viewCounts = await trackingService.getCountsByPostIds(postIds); return posts.map((post) => ({ ...post, viewCount: viewCounts[post.id] || 0, })); } // ... 다른 메서드 }
src/posts/posts.controller.ts
업데이트:
새로 만든 서비스 메서드를 사용하도록 root
메서드를 수정합니다.
// src/posts/posts.controller.ts // ... export class PostsController { // ... constructor @Get() @Render('index') async root(@Request() req) { const posts = await this.postsService.findAllWithViewCount(this.trackingService); return { posts, user: req.session.user }; } // ... }
마지막으로 views/index.ejs
템플릿을 업데이트하여 조회수를 표시합니다.
<div class="post-list"> <% posts.forEach(post => { %> <article class="post-item"> <h2><a href="/posts/<%= post.id %>"><%= post.title %></a></h2> <p><%= post.content.substring(0, 150) %>...</p> <small> <%= new Date(post.createdAt).toLocaleDateString() %> | Views: <%= post.viewCount %></small> </article> <% }) %></div>
실행 및 테스트
애플리케이션을 다시 시작합니다.
npm run start:dev
브라우저를 열고 방문합니다: http://localhost:3000/
블로그 목록에서 각 게시물 옆에 "Views: 0"이 표시됩니다.
기사의 상세 페이지로 들어가 페이지를 몇 번 새로고침합니다. 해당 기사의 조회수가 적절하게 증가했음을 알 수 있습니다.
결론
이제 Nest.js 블로그에 백엔드 조회수 추적 시스템을 성공적으로 추가했습니다. 사용자 방문 데이터가 이제 여러분의 손에 있습니다.
이 원시 데이터를 사용하면 더 심층적인 데이터 작업과 분석을 수행할 수 있습니다. 예를 들면 다음과 같습니다.
- 중복 제거: 특정 시간 창(예: 하루) 동안 동일한 IP 주소에서 여러 번 방문해도 한 번의 조회로 계산합니다.
- 봇 필터링: User-Agent를 분석하여 검색 엔진 크롤러의 방문을 식별하고 필터링합니다.
- 데이터 대시보드: 차트를 사용하여 게시물 조회수 추세를 시각화하는 비공개 페이지를 만듭니다.
데이터는 모두 여러분의 것이므로 이러한 탐색은 여러분에게 맡기겠습니다.
블로그가 Leapcell에 배포된 경우, Leapcell은 이미 웹 분석 기능을 자동으로 활성화했습니다(완전 무료).
Leapcell의 웹 분석에는 실용적이고 강력한 방문자 분석 기능이 많이 포함되어 있습니다. 직접 개발할 필요 없이 방문자 행동에 대한 기본 분석을 쉽게 수행할 수 있습니다.
이전 튜토리얼: