junyeokk
Blog
NestJS·2025. 11. 15

NestJS 서버사이드 HTML 템플릿 렌더링

NestJS는 기본적으로 REST API 서버로 많이 사용되지만, 가끔 서버에서 직접 HTML을 렌더링해서 내려줘야 하는 상황이 있다. QR 코드 다운로드 페이지, 이메일 템플릿 미리보기, 관리자용 간단한 대시보드, 또는 OAuth 콜백 후 "로그인 성공" 페이지 같은 것들이다.

이런 페이지를 위해 별도의 프론트엔드 프로젝트를 만드는 건 과하다. HTML 파일 한두 개면 충분한 상황에서 React나 Next.js를 세팅하는 건 배보다 배꼽이 더 크다. NestJS는 Express(또는 Fastify) 위에서 동작하기 때문에, Express가 지원하는 템플릿 엔진을 그대로 사용할 수 있다. 공식적으로 MVC(Model-View-Controller) 패턴을 지원하며, 이를 통해 서버에서 데이터를 바인딩한 HTML을 직접 응답으로 내려줄 수 있다.


왜 서버사이드 렌더링인가

SPA(Single Page Application)가 대세인 요즘, 서버에서 HTML을 렌더링하는 게 왜 필요한지 의문이 들 수 있다. 몇 가지 실제 시나리오를 보자.

1. 독립적인 단발성 페이지

QR 코드 다운로드 페이지를 예로 들어보자. 사용자가 /download/qr/:id 같은 URL에 접근하면, 서버가 QR 이미지를 생성하고 다운로드 버튼이 포함된 간단한 HTML 페이지를 보여주면 된다. 이걸 위해 별도 프론트엔드를 배포하는 건 낭비다.

2. 이메일 HTML 생성

이메일 본문은 HTML이다. 서버에서 템플릿 엔진으로 사용자 이름, 인증 코드 등을 바인딩해서 HTML 문자열을 만들고, 그걸 메일 서비스에 넘기면 된다. 프론트엔드가 개입할 이유가 없다.

3. OAuth 콜백 페이지

소셜 로그인 후 리다이렉트되는 중간 페이지. "로그인 성공, 앱으로 돌아가는 중..." 같은 메시지를 보여주면서 토큰을 처리하는 페이지다.

4. 외부 공유 링크

Open Graph 메타태그가 필요한 공유 페이지. 카카오톡이나 슬랙에 링크를 붙여넣었을 때 미리보기가 제대로 나오려면, 서버가 해당 콘텐츠의 메타태그를 포함한 HTML을 직접 내려줘야 한다.

이런 상황에서 NestJS의 MVC 기능은 아주 실용적인 선택이다.


템플릿 엔진 선택

NestJS는 Express 호환 템플릿 엔진이면 뭐든 사용할 수 있다. 대표적인 선택지 세 가지를 비교해보자.

Handlebars (hbs)

가장 널리 쓰이는 선택이다. 로직을 최소화한 "logic-less" 철학을 가지고 있어서, 템플릿 안에서 복잡한 연산을 하지 못하게 제한한다. 이게 장점이다 — 뷰 레이어가 단순해지고, 데이터 가공은 컨트롤러에서 다 끝내야 한다.

handlebars
<h1>{{title}}</h1>
<ul>
  {{#each items}}
    <li>{{this.name}} - {{this.price}}원</li>
  {{/each}}
</ul>

레이아웃(layout) 시스템이 내장되어 있어서, 공통 HTML 구조(header, footer)를 한 번만 정의하고 페이지별로 body만 바꿀 수 있다. Partial(부분 템플릿)도 지원해서 재사용 가능한 컴포넌트를 만들 수 있다.

EJS (Embedded JavaScript)

JavaScript 코드를 템플릿 안에 직접 삽입할 수 있다. JSP나 PHP처럼 <% %> 태그 안에 로직을 작성한다.

html
<h1><%= title %></h1>
<ul>
  <% items.forEach(item => { %>
    <li><%= item.name %> - <%= item.price %>원</li>
  <% }) %>
</ul>

자유도가 높지만, 그만큼 템플릿이 지저분해지기 쉽다. 간단한 페이지에는 괜찮지만, 복잡한 레이아웃 구성에서는 Handlebars보다 불편하다.

Pug (구 Jade)

들여쓰기 기반의 간결한 문법을 사용한다. HTML 태그를 축약해서 쓰기 때문에 코드량이 줄어든다.

pug
h1= title
ul
  each item in items
    li #{item.name} - #{item.price}

깔끔하지만 학습 곡선이 있고, 일반적인 HTML과 모양이 달라서 디자이너와 협업할 때 불편할 수 있다.

권장: 특별한 이유가 없으면 Handlebars(hbs)를 추천한다. NestJS 공식 문서에서도 hbs를 예시로 사용하고, 로직과 뷰의 분리가 NestJS의 아키텍처 철학과도 맞다.


기본 설정

1. 패키지 설치

bash
npm install hbs

hbs는 Express용 Handlebars 뷰 엔진 래퍼다. 내부적으로 handlebars를 사용하지만, Express의 view engine 인터페이스에 맞춰져 있어서 별도 어댑터 없이 바로 쓸 수 있다.

2. main.ts 설정

typescript
import { NestFactory } from '@nestjs/core';
import { NestExpressApplication } from '@nestjs/platform-express';
import { join } from 'path';
import { AppModule } from './app.module';

async function bootstrap() {
  // 제네릭으로 NestExpressApplication을 명시해야 Express 전용 메서드 사용 가능
  const app = await NestFactory.create<NestExpressApplication>(AppModule);

  // 정적 파일 서빙 (CSS, JS, 이미지 등)
  app.useStaticAssets(join(__dirname, '..', 'public'));

  // 뷰 템플릿 파일 위치
  app.setBaseViewsDir(join(__dirname, '..', 'views'));

  // 템플릿 엔진 지정
  app.setViewEngine('hbs');

  await app.listen(3000);
}
bootstrap();

여기서 주의할 점이 있다. NestFactory.create()NestExpressApplication 제네릭을 넘기지 않으면 useStaticAssets, setBaseViewsDir, setViewEngine 메서드가 타입에 나타나지 않는다. NestJS의 기본 INestApplication 인터페이스는 플랫폼 중립적이라 Express 전용 메서드를 포함하지 않기 때문이다.

3. 디렉토리 구조

text
project-root/
├── public/              # 정적 파일
│   ├── css/
│   │   └── style.css
│   └── images/
├── views/               # 템플릿 파일
│   ├── layouts/
│   │   └── main.hbs     # 기본 레이아웃
│   ├── partials/
│   │   ├── header.hbs
│   │   └── footer.hbs
│   ├── index.hbs
│   └── qr-download.hbs
├── src/
│   ├── main.ts
│   └── app.controller.ts
└── package.json

views/ 폴더는 src/ 바깥에 둔다. TypeScript 컴파일 대상이 아니기 때문이다. public/ 폴더도 마찬가지로 프로젝트 루트에 둔다.


@Render 데코레이터

NestJS에서 HTML을 렌더링하는 핵심은 @Render() 데코레이터다. 컨트롤러 메서드에 이 데코레이터를 붙이면, 메서드의 반환값이 JSON 응답이 아니라 템플릿 엔진에 전달되어 HTML로 변환된다.

typescript
import { Controller, Get, Render, Param } from '@nestjs/common';

@Controller()
export class AppController {
  @Get()
  @Render('index')  // views/index.hbs를 렌더링
  getHome() {
    return { title: '홈페이지', message: '환영합니다' };
  }
}

@Render('index')에서 'index'views/ 디렉토리 기준의 템플릿 파일명이다(확장자 생략). 메서드가 반환하는 객체는 템플릿에 바인딩될 데이터다. views/index.hbs에서 {{title}}{{message}}로 접근할 수 있다.

handlebars
<!-- views/index.hbs -->
<!DOCTYPE html>
<html>
<head>
  <title>{{title}}</title>
  <link rel="stylesheet" href="/css/style.css">
</head>
<body>
  <h1>{{message}}</h1>
</body>
</html>

@Render vs @Res

@Render() 대신 Express의 Response 객체를 직접 사용할 수도 있다.

typescript
import { Controller, Get, Res } from '@nestjs/common';
import { Response } from 'express';

@Controller()
export class AppController {
  @Get('manual')
  getManual(@Res() res: Response) {
    res.render('index', { title: '수동 렌더링', message: '직접 호출' });
  }
}

@Res()를 사용하면 NestJS의 응답 처리 파이프라인(인터셉터, 예외 필터 등)을 우회하게 된다. 특별한 이유가 없으면 @Render()를 사용하는 것이 좋다. 다만, 조건부로 JSON을 반환할지 HTML을 반환할지 결정해야 하는 경우에는 @Res()가 필요할 수 있다.

@Res({ passthrough: true })를 쓰면 NestJS 파이프라인을 유지하면서 Response 객체에 접근할 수 있지만, 이 경우 res.render()는 사용할 수 없다. render()가 직접 응답을 보내버리기 때문이다.


레이아웃 시스템

실제 프로젝트에서는 여러 페이지가 공통 HTML 구조를 공유한다. <head>, 네비게이션, 푸터 등을 매번 복사하는 건 유지보수 지옥이다. Handlebars의 레이아웃 시스템으로 이 문제를 해결한다.

기본 레이아웃 설정

typescript
// main.ts
import * as hbs from 'hbs';

async function bootstrap() {
  const app = await NestFactory.create<NestExpressApplication>(AppModule);

  app.useStaticAssets(join(__dirname, '..', 'public'));
  app.setBaseViewsDir(join(__dirname, '..', 'views'));
  app.setViewEngine('hbs');

  // Partial 등록
  hbs.registerPartials(join(__dirname, '..', 'views', 'partials'));

  await app.listen(3000);
}

hbs 패키지 자체에는 레이아웃 기능이 기본 제공되지 않는다. Express에서 express-handlebars를 쓸 때와 달리, hbs에서는 partial을 활용해 레이아웃을 구성한다.

Partial로 레이아웃 구성

handlebars
<!-- views/partials/header.hbs -->
<!DOCTYPE html>
<html lang="ko">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>{{title}}</title>
  <link rel="stylesheet" href="/css/style.css">
</head>
<body>
  <nav>
    <a href="/">홈</a>
  </nav>
  <main>
handlebars
<!-- views/partials/footer.hbs -->
  </main>
  <footer>
    <p>&copy; 2026 My App</p>
  </footer>
</body>
</html>
handlebars
<!-- views/qr-download.hbs -->
{{> header}}

<div class="qr-container">
  <h1>QR 코드 다운로드</h1>
  <img src="{{qrImageUrl}}" alt="QR Code">
  <a href="{{downloadUrl}}" class="btn">다운로드</a>
</div>

{{> footer}}

{{> partialName}}이 partial을 삽입하는 문법이다. views/partials/ 폴더에 등록된 .hbs 파일명에서 확장자를 뺀 이름을 사용한다.

express-handlebars로 진짜 레이아웃 사용하기

레이아웃을 제대로 사용하려면 hbs 대신 express-handlebars를 쓰는 방법도 있다.

bash
npm install express-handlebars
typescript
// main.ts
import { engine } from 'express-handlebars';

async function bootstrap() {
  const app = await NestFactory.create<NestExpressApplication>(AppModule);

  app.useStaticAssets(join(__dirname, '..', 'public'));
  app.setBaseViewsDir(join(__dirname, '..', 'views'));

  // express-handlebars 엔진 등록
  app.engine('hbs', engine({
    extname: '.hbs',
    defaultLayout: 'main',  // views/layouts/main.hbs를 기본 레이아웃으로
    layoutsDir: join(__dirname, '..', 'views', 'layouts'),
    partialsDir: join(__dirname, '..', 'views', 'partials'),
  }));
  app.setViewEngine('hbs');

  await app.listen(3000);
}
handlebars
<!-- views/layouts/main.hbs -->
<!DOCTYPE html>
<html lang="ko">
<head>
  <meta charset="UTF-8">
  <title>{{title}}</title>
  <link rel="stylesheet" href="/css/style.css">
</head>
<body>
  {{> header}}
  <main>
    {{{body}}}  <!-- 각 페이지의 내용이 여기에 삽입됨 -->
  </main>
  {{> footer}}
</body>
</html>

{{{body}}}에 주목하자. 이중 중괄호 {{}} 대신 삼중 중괄호 {{{}}}를 사용한다. 이중 중괄호는 HTML을 이스케이프하지만, 삼중 중괄호는 HTML을 그대로 삽입한다. 레이아웃의 body에는 페이지의 HTML이 들어가야 하므로 삼중 중괄호가 필요하다.


실전 예시: QR 코드 다운로드 페이지

실제로 서버사이드 렌더링이 빛나는 사례를 하나 구현해보자.

typescript
// qr.controller.ts
import { Controller, Get, Param, Render, NotFoundException } from '@nestjs/common';
import { QrService } from './qr.service';

@Controller('download')
export class QrController {
  constructor(private readonly qrService: QrService) {}

  @Get('qr/:id')
  @Render('qr-download')
  async getQrDownloadPage(@Param('id') id: string) {
    const qrData = await this.qrService.findById(id);
    if (!qrData) {
      throw new NotFoundException('QR 코드를 찾을 수 없습니다');
    }

    return {
      title: 'QR 코드 다운로드',
      qrImageUrl: qrData.imageUrl,
      downloadUrl: `/api/qr/${id}/file`,
      createdAt: qrData.createdAt.toLocaleDateString('ko-KR'),
    };
  }
}
handlebars
<!-- views/qr-download.hbs -->
<div class="qr-page">
  <div class="qr-card">
    <h1>QR 코드</h1>
    <p class="date">생성일: {{createdAt}}</p>
    <div class="qr-image">
      <img src="{{qrImageUrl}}" alt="QR Code" width="256" height="256">
    </div>
    <a href="{{downloadUrl}}" class="download-btn">
      📥 이미지 다운로드
    </a>
  </div>
</div>

컨트롤러가 데이터를 준비하고, 템플릿이 표현을 담당한다. 깔끔한 관심사 분리다. 프론트엔드 프레임워크 없이도 충분히 쓸만한 페이지가 나온다.


동적 렌더링 vs 정적 렌더링

동적 렌더링

위에서 본 것처럼 @Render()로 매 요청마다 데이터를 바인딩해서 HTML을 생성하는 방식이다. 데이터가 요청마다 달라지는 페이지에 적합하다.

HTML 문자열 직접 생성

템플릿을 파일로 관리하지 않고, 코드 안에서 Handlebars를 직접 사용해 HTML 문자열을 만들 수도 있다. 이메일 발송 같은 경우에 유용하다.

typescript
import * as Handlebars from 'handlebars';
import { readFileSync } from 'fs';
import { join } from 'path';

@Injectable()
export class EmailService {
  private readonly welcomeTemplate: Handlebars.TemplateDelegate;

  constructor() {
    const templateSource = readFileSync(
      join(__dirname, '..', 'templates', 'welcome-email.hbs'),
      'utf-8',
    );
    this.welcomeTemplate = Handlebars.compile(templateSource);
  }

  generateWelcomeEmail(userName: string, verifyUrl: string): string {
    return this.welcomeTemplate({ userName, verifyUrl });
  }
}

이 방식은 Express의 뷰 엔진 시스템을 거치지 않는다. Handlebars 라이브러리를 직접 사용해서 템플릿을 컴파일하고, 데이터를 바인딩한 HTML 문자열을 반환한다. HTTP 응답이 아니라 메일 서비스의 입력으로 사용할 때 적합하다.


Handlebars Helper

기본 Handlebars 문법으로 부족할 때, 커스텀 헬퍼를 등록해서 기능을 확장할 수 있다.

typescript
// main.ts
import * as hbs from 'hbs';

// 등호 비교 헬퍼
hbs.registerHelper('eq', function (a: unknown, b: unknown) {
  return a === b;
});

// 날짜 포맷 헬퍼
hbs.registerHelper('formatDate', function (date: Date, format: string) {
  return new Intl.DateTimeFormat('ko-KR', {
    year: 'numeric',
    month: 'long',
    day: 'numeric',
  }).format(new Date(date));
});

// 조건부 CSS 클래스 헬퍼
hbs.registerHelper('activeClass', function (current: string, target: string) {
  return current === target ? 'active' : '';
});
handlebars
<!-- 사용 예시 -->
<p>가입일: {{formatDate user.createdAt}}</p>

{{#if (eq status "active")}}
  <span class="badge-active">활성</span>
{{else}}
  <span class="badge-inactive">비활성</span>
{{/if}}

<nav>
  <a href="/" class="{{activeClass currentPage 'home'}}">홈</a>
  <a href="/about" class="{{activeClass currentPage 'about'}}">소개</a>
</nav>

헬퍼는 main.ts에서 앱 부트스트랩 시점에 한 번 등록하면 모든 템플릿에서 사용할 수 있다. 로직이 많아지면 별도 파일로 분리해서 registerHelpers()로 일괄 등록하는 것이 깔끔하다.


Fastify 환경에서의 차이점

NestJS는 Express 외에 Fastify도 지원한다. Fastify를 사용하는 경우 템플릿 엔진 설정이 약간 다르다.

bash
npm install @nestjs/platform-fastify @fastify/view handlebars
typescript
import { NestFactory } from '@nestjs/core';
import { NestFastifyApplication, FastifyAdapter } from '@nestjs/platform-fastify';
import { join } from 'path';

async function bootstrap() {
  const app = await NestFactory.create<NestFastifyApplication>(
    AppModule,
    new FastifyAdapter(),
  );

  app.useStaticAssets({
    root: join(__dirname, '..', 'public'),
    prefix: '/public/',
  });

  app.setViewEngine({
    engine: { handlebars: require('handlebars') },
    templates: join(__dirname, '..', 'views'),
  });

  await app.listen(3000, '0.0.0.0');
}

Fastify에서는 @fastify/view 플러그인이 템플릿 렌더링을 담당한다. Express와 달리 setViewEngine()에 객체를 전달하며, 엔진 라이브러리를 직접 지정해야 한다. 컨트롤러 코드(@Render, @Res 등)는 동일하게 사용할 수 있다.


보안 고려사항

서버사이드 렌더링에서 가장 주의해야 할 보안 이슈는 XSS(Cross-Site Scripting)다.

HTML 이스케이핑

Handlebars의 {{변수}}는 기본적으로 HTML을 이스케이프한다. <script>alert('xss')</script> 같은 값이 들어와도 &lt;script&gt;... 으로 변환되어 실행되지 않는다.

handlebars
<!-- 안전: HTML 이스케이프됨 -->
<p>사용자 이름: {{userName}}</p>

<!-- 위험: HTML이 그대로 삽입됨 -->
<div>{{{userContent}}}</div>

삼중 중괄호 {{{}}}는 이스케이프를 건너뛰므로, 사용자 입력을 삼중 중괄호로 출력하면 안 된다. 삼중 중괄호는 레이아웃의 {{{body}}}처럼 신뢰할 수 있는 서버 생성 HTML에만 사용해야 한다.

CSP (Content Security Policy)

서버에서 렌더링하는 HTML에도 CSP 헤더를 설정하는 것이 좋다. 인라인 스크립트를 제한하고, 외부 리소스 출처를 화이트리스트로 관리한다.

typescript
// main.ts
app.use(helmet({
  contentSecurityPolicy: {
    directives: {
      defaultSrc: ["'self'"],
      scriptSrc: ["'self'"],
      styleSrc: ["'self'", "'unsafe-inline'"],  // CSS는 인라인 허용이 필요할 수 있음
      imgSrc: ["'self'", "data:", "https:"],
    },
  },
}));

API 서버와의 공존

대부분의 NestJS 프로젝트에서 서버사이드 렌더링은 전체 앱의 일부분일 뿐이다. API 엔드포인트와 렌더링 엔드포인트가 한 서버에 공존한다. 이때 구조를 깔끔하게 유지하는 방법이 있다.

typescript
// API는 /api 프리픽스
@Controller('api/users')
export class UsersApiController {
  @Get()
  findAll() {
    return this.usersService.findAll(); // JSON 응답
  }
}

// 페이지 렌더링은 별도 컨트롤러
@Controller('pages')
export class PagesController {
  @Get('dashboard')
  @Render('dashboard')
  getDashboard() {
    return { title: '대시보드' }; // HTML 응답
  }
}

API 컨트롤러와 페이지 컨트롤러를 명확히 분리하면, 어떤 엔드포인트가 JSON을 반환하고 어떤 엔드포인트가 HTML을 반환하는지 한눈에 파악할 수 있다. Global prefix(app.setGlobalPrefix('api'))를 사용하는 경우에는 페이지 컨트롤러를 exclude 목록에 추가해야 한다.

typescript
app.setGlobalPrefix('api', {
  exclude: ['pages/(.*)'],  // pages/ 경로는 API 프리픽스 제외
});

정리

NestJS의 서버사이드 HTML 렌더링은 거창한 기능이 아니다. Express의 뷰 엔진 시스템을 그대로 가져다 쓰되, NestJS의 데코레이터(@Render)로 깔끔하게 감싼 것이다.

개념역할
setViewEngine()템플릿 엔진 종류 지정
setBaseViewsDir()템플릿 파일 디렉토리
useStaticAssets()CSS/JS/이미지 등 정적 파일 서빙
@Render('template')컨트롤러 메서드에서 템플릿 렌더링
{{{body}}}레이아웃에서 페이지 내용 삽입 (Handlebars)
{{> partial}}Partial 템플릿 삽입

전체 앱을 SSR로 만들 필요는 없다. API 서버를 기본으로 하되, HTML이 필요한 몇몇 페이지만 서버사이드로 렌더링하는 하이브리드 구조가 가장 실용적이다. QR 페이지 하나 만들자고 Next.js를 꺼내들 필요는 없다.

관련 문서