본 내용은 내일배움캠프에서 활동한 내용을 기록한 글입니다.
필요한 패키지 설치 (pull 받으면 자동으로 받아짐)
$ npm install axios
$ npm install ejs
ejs를 위한 디렉토리 구성 (파일 이름은 임시로 지정함)
📦views
┣ 📂auth
┃ ┣ 📜auth.view.controller.ts
┃ ┗ 📜sign.view.ejs
┣ 📂common
┃ ┗ 📜header.view.ejs
┣ 📂users
┣ 📜index.view.controller.ts
┗ 📜index.view.ejs
📦public
┣ 📂css
┃ ┣ 📂auth
┃ ┃ ┗ 📜sign.css
┃ ┣ 📂common
┃ ┃ ┗ 📜header.css
┃ ┗ 📜index.css
┗ 📂js
┃ ┣ 📂auth
┃ ┃ ┗ 📜sign.js
┃ ┗ 📂common
┃ ┃ ┗ 📜header.js
controller 파일은 해당 ejs와 URL 주소를 연결하기 위한 파일
ejs 파일은 실제로 사용자에게 보여질 HTML 파일
views 폴더에 해당 기능마다 폴더를 생성해서 controller와 ejs 파일을 만들어서 작업 진행
// main.ts
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { ConfigService } from '@nestjs/config';
import { ValidationPipe } from '@nestjs/common';
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
import { NestExpressApplication } from '@nestjs/platform-express';
import { resolve } from 'path';
async function bootstrap() {
// NestExpressApplication는 Express 기능을 Nest.js에서 활용할 수 있게 해줌
const app = await NestFactory.create<NestExpressApplication>(AppModule);
const configService = app.get(ConfigService);
const port = configService.get<number>('SERVER_PORT');
app.setGlobalPrefix('/');
app.useGlobalPipes(
new ValidationPipe({
transform: true,
})
);
const config = new DocumentBuilder()
.setTitle('티켓 예매 및 중고 거래 서비스')
.setVersion('1.0')
.addTag('Ticketing')
.addBearerAuth({ type: 'http', scheme: 'bearer', bearerFormat: 'JWT' })
.build();
const document = SwaggerModule.createDocument(app, config);
SwaggerModule.setup('/', app, document, {
swaggerOptions: {
persistAuthorization: true, // 새로고침 시에도 JWT 유지하기
tagsSorter: 'alpha', // API 그룹 정렬을 알파벳 순으로
operationsSorter: 'alpha', // API 그룹 내 정렬을 알파벳 순으로
},
});
// ejs 설정
app.useStaticAssets(resolve('./src/public')); // 정적 파일 경로 설정 (js, css, img)
app.setBaseViewsDir(resolve('./src/views')); // 클라이언트에 보여질 파일들의 경로 설정
app.setViewEngine('ejs'); // 클라이언트엑세 보여질 템플릿 엔진 설정
await app.listen(port);
}
bootstrap();
나중에 View 컨트롤러가 많아지면 View를 위한 모듈을 생각 중…
// app.module.ts
import { MiddlewareConsumer, Module } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { TypeOrmModule } from '@nestjs/typeorm';
import { configModuleValidationSchema } from 'src/configs/env-validation.config';
import { typeOrmModuleOptions } from 'src/configs/database.config';
import { AuthModule } from './modules/auth/auth.module';
import { UsersModule } from './modules/users/users.module';
import { ShowsModule } from './modules/shows/shows.module';
import { TradesModule } from './modules/trades/trades.module';
import { ImagesModule } from './modules/images/images.module';
import { RedisModule } from './modules/redis/redis.module';
import { BullModule } from '@nestjs/bullmq';
import { ViewsController } from './views/index.view.controller';
import { AuthViewsController } from './views/auth/auth.view.controller';
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
validationSchema: configModuleValidationSchema,
}),
BullModule.forRootAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (configService: ConfigService) => ({
connection: {
host: configService.get<string>('REDIS_HOST'),
port: configService.get<number>('REDIS_PORT'),
password: configService.get<string>('REDIS_PASSWORD'),
},
}),
}),
TypeOrmModule.forRootAsync(typeOrmModuleOptions),
AuthModule,
UsersModule,
ShowsModule,
TradesModule,
ImagesModule,
RedisModule,
],
controllers: [AppController, ViewsController, AuthViewsController],
providers: [AppService],
})
export class AppModule {}
index.view 를 메인 view 파일로 지정
사실 메인 페이지의 주소를 지정하기 위해서 index.view.controller.ts를 사용
// index.view.controller.ts
import { Controller, Get, Render } from '@nestjs/common';
@Controller('views')
export class ViewsController {
@Get()
@Render('index.view.ejs')
moveToHome() {}
}
@Render() 데코레이터를 통해서 어떤 파일을 통해 클라이언트에게 보여질지 설정
즉, localhost:3000/views 경로로 들어가면 index.view.ejs 파일이 랜더링 된다는 의미
원래 ejs 자체가 서버 사이드 랜더링(SSR)를 위해 주로 사용된다고 하지만 이 프로젝트에서는 클라이언트 사이드 랜더링(CSR)를 위해서 컨트롤러는 그저 URL 설정을 위한 용도로만 사용
서버 사이드 랜더링(SSR)은 서버에서 HTML을 생성하여 클라이언트에게 전달하는 방식
클라이언트 사이드 랜더링(CSR)은 클라이언트(브라우저)에서 JavaScript를 통해 HTML을 생성하는 방식
이번 프로젝트에서 필요한 페이지를 기준으로 컨트롤러에 주소와 ejs 파일을 설정하면 됨
<head>
테그에서 필요한 JS, CSS를 설정함
<%- include() %>를 사용하면 공통적으로 사용하는 ejs 형식을 불러다 사용 가능함
그 밖의 내용은 대부분 캠프 초반에 학습한 HTML 코드임
<!-- index.view.ejs -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Nest & EJS ❤</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.2.3/dist/css/bootstrap.min.css" rel="stylesheet"
integrity="sha384-rbsA2VBKQhggwzxH7pPCaAqO46MgnOM80zW1RWuH61DGLwZJEdK2Kadq2F9CUG65" crossorigin="anonymous">
<link rel="stylesheet" href="/css/index.css">
<script src="https://cdn.jsdelivr.net/npm/@popperjs/core@2.11.6/dist/umd/popper.min.js"
integrity="sha384-oBqDVmMz9ATKxIep9tiCxS/Z9fNfEXiDAYTujMAeBAsjFuCZSmKbSSUnQlmh/jp3"
crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.2.3/dist/js/bootstrap.min.js"
integrity="sha384-cuYeSxntonz0PPNlHhBs68uyIAVpIIOZZ5JqeqvYYIcEL727kskC66kF92t6Xl2V"
crossorigin="anonymous"></script>
</head>
<body>
<!-- include 안에는 상대 경로 작성 -->
<%- include('./common/header.view.ejs') %>
<main>
<div id="carouselExampleIndicators" class="carousel slide" data-bs-ride="true">
<div class="carousel-indicators">
<button type="button" data-bs-target="#carouselExampleIndicators" data-bs-slide-to="0"
class="active" aria-current="true" aria-label="Slide 1"></button>
<button type="button" data-bs-target="#carouselExampleIndicators" data-bs-slide-to="1"
aria-label="Slide 2"></button>
<button type="button" data-bs-target="#carouselExampleIndicators" data-bs-slide-to="2"
aria-label="Slide 3"></button>
</div>
<div class="carousel-inner">
<div class="carousel-item active">
<img src="https://velog.velcdn.com/images/my_code/post/33437564-f78e-42f0-9b6a-d6dd2e950e40/image.png"
class="d-block w-100" alt="...">
</div>
<div class="carousel-item">
<img src="https://velog.velcdn.com/images/my_code/post/33437564-f78e-42f0-9b6a-d6dd2e950e40/image.png"
class="d-block w-100" alt="...">
</div>
<div class="carousel-item">
<img src="https://velog.velcdn.com/images/my_code/post/33437564-f78e-42f0-9b6a-d6dd2e950e40/image.png"
class="d-block w-100" alt="...">
</div>
</div>
<div>
<button class="carousel-control-prev" type="button" data-bs-target="#carouselExampleIndicators"
data-bs-slide="prev">
<span class="carousel-control-prev-icon" aria-hidden="true"></span>
<span class="visually-hidden">Previous</span>
</button>
<button class="carousel-control-next" type="button" data-bs-target="#carouselExampleIndicators"
data-bs-slide="next">
<span class="carousel-control-next-icon" aria-hidden="true"></span>
<span class="visually-hidden">Next</span>
</button>
</div>
</div>
</main>
<footer>
</footer>
</body>
</html>
로그인 동작 순서
사용자가 이메일과 비밀번호를 입력하고 로그인 버튼을 클릭함
해당 버튼에 설정된 이벤트 동작
입력한 이메일과 비밀번호를 JS 코드로 가져옴
JS 코드에서 axios를 통해서 백엔드와 통신
이 때, 사용자가 입력한 이메일과 비밀번호를 같이 넘김
만약 잘못된 이메일이나 비밀번호를 넘길 경우 에러를 출력함
성공한다면 백엔드의 로그인 API는 해당 사용자의 토큰을 반환함
프론트엔트 측에서 반환받은 토큰을 localStorage에 저장 (보안은 취약하지만 일단 임시로 사용)
혹시나 사용자 인가를 위해 토큰이 필요하다면 해당 API를 axios로 요청할 때 headers에 토큰을 넣어서 전달
로그인 기능 ejs 및 JS 예제 (Auth)
View 컨트롤러 예제
localhost:3000/views/auth/sign 에 접속하면 auth/sign.view.ejs 파일을 랜더링 함
import { Controller, Get, Render } from '@nestjs/common';
@Controller('views/auth')
export class AuthViewsController {
@Get('/sign')
@Render('auth/sign.view.ejs')
signIn() {}
}
아직은 기본적인 틀인 예제
여기는 클라이언트에게 보여줄 HTML 파일이라고 생각해도 됨
<!-- sign.view.ejs -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Nest & EJS ❤</title>
<!-- 부트스트랩 CSS CDN-->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.2.3/dist/css/bootstrap.min.css" rel="stylesheet"
integrity="sha384-rbsA2VBKQhggwzxH7pPCaAqO46MgnOM80zW1RWuH61DGLwZJEdK2Kadq2F9CUG65" crossorigin="anonymous">
<!-- 부트스트랩 JS CDN -->
<script src="https://cdn.jsdelivr.net/npm/@popperjs/core@2.11.6/dist/umd/popper.min.js"
integrity="sha384-oBqDVmMz9ATKxIep9tiCxS/Z9fNfEXiDAYTujMAeBAsjFuCZSmKbSSUnQlmh/jp3"
crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.2.3/dist/js/bootstrap.min.js"
integrity="sha384-cuYeSxntonz0PPNlHhBs68uyIAVpIIOZZ5JqeqvYYIcEL727kskC66kF92t6Xl2V"
crossorigin="anonymous"></script>
<!-- 로그인 페이지에 필요한 CSS 및 JS -->
<link rel="stylesheet" href="/css/auth/sign.css">
<script type="module" src="/js/auth/sign.js"></script>
</head>
<body>
<!-- include 안에는 상대 경로 작성 -->
<%- include('../common/header.view.ejs') %>
<main>
<div class="container">
<!-- Heading -->
<h1>SIGN IN</h1>
<!-- Links -->
<ul class="links">
<li>
<a href="#" id="signin">SIGN IN</a>
</li>
<li>
<a href="#" id="signup">SIGN UP</a>
</li>
<li>
<a href="#" id="reset">RESET</a>
</li>
</ul>
<!-- Form -->
<form class="sign-form">
<!-- email input -->
<div class="first-input input__block first-input__block">
<input type="email" placeholder="Email" class="input" id="email" />
</div>
<!-- password input -->
<div class="input__block">
<input type="password" placeholder="Password" class="input" id="password" />
</div>
<!-- repeat password input -->
<div class="input__block">
<input type="password" placeholder="Repeat password" class="input repeat__password"
id="repeat__password" />
</div>
<!-- sign in button -->
<button class="signin__btn">
Sign in
</button>
</form>
<!-- separator -->
<div class="separator">
<p>OR</p>
</div>
<!-- google button -->
<button class="google__btn">
<i class="fa fa-google"></i>
Sign in with Google
</button>
</div>
</main>
<footer>
</footer>
</body>
</html>
document.addEventListener('DOMContentLoaded', function () {
const signUp = document.querySelector('#signup');
const signIn = document.querySelector('#signin');
const reset = document.querySelector('#reset');
const firstInput = document.querySelector('.first-input');
const hiddenInput = document.querySelector('#repeat__password');
const signInBtn = document.querySelector('.signin__btn');
// 이미 로그인 했는지 확인
if (window.localStorage.getItem('accessToken')) {
alert('이미 로그인한 사용자입니다.');
window.location.href = '/views';
}
//----------- sign up ---------------------
signUp.addEventListener('click', function (e) {
e.preventDefault();
const h1 = signUp.closest('li').parentNode.previousElementSibling; // h1 요소 찾기
h1.textContent = 'SIGN UP';
signUp.parentElement.style.opacity = '1';
Array.from(signUp.parentElement.parentElement.children).forEach(function (sibling) {
if (sibling !== signUp.parentElement) sibling.style.opacity = '.6';
});
firstInput.classList.remove('first-input__block');
firstInput.classList.add('signup-input__block');
hiddenInput.style.opacity = '1';
hiddenInput.style.display = 'block';
signInBtn.textContent = 'Sign up';
// 회원 가입 이벤트 연결
});
//----------- sign in ---------------------
signIn.addEventListener('click', function (e) {
e.preventDefault();
const h1 = signIn.closest('li').parentNode.previousElementSibling; // h1 요소 찾기
h1.textContent = 'SIGN IN';
signIn.parentElement.style.opacity = '1';
Array.from(signIn.parentElement.parentElement.children).forEach(function (sibling) {
if (sibling !== signIn.parentElement) sibling.style.opacity = '.6';
});
firstInput.classList.add('first-input__block');
firstInput.classList.remove('signup-input__block');
hiddenInput.style.opacity = '0';
hiddenInput.style.display = 'none';
signInBtn.textContent = 'Sign in';
});
// 로그인 이벤트 연결
signInBtn.addEventListener('click', async (e) => {
e.preventDefault(); // 기본 이벤트 동작을 막기 위한 부분
// 로그인 API에게 보낼 사용자 입력 DTO 객체
const signInDto = {
email: document.getElementById('email').value,
password: document.getElementById('password').value,
};
try {
// 백엔드 로그인 API 호출
const result = await axios.post('/auth/sign-in', signInDto);
// 반환된 토큰을 localStorage에 저장
window.localStorage.setItem('accessToken', result.data.accessToken);
window.localStorage.setItem('refreshToken', result.data.refreshToken);
// 로그인 완료 후 메인 페이지로 이동
window.location.href = '/views';
} catch (err) {
// 로그인 실패 시 에러 처리 (에러 메세지 출력)
console.log(err.response.data);
const errorMessage = err.response.data.message;
alert(errorMessage);
}
});
//----------- reset ---------------------
reset.addEventListener('click', function (e) {
e.preventDefault();
const inputs = document.querySelectorAll('.input__block .input');
inputs.forEach(function (input) {
input.value = '';
});
});
});
일단 내일 오전에 팀원들에게 이러한 내용을 작성했다고 전파하고 사용방법을 간단하게 이야기할 예정
그리고 기능 구현이 끝난 사람부터 본인이 맡았던 기능 구현에 대한 프론트엔드를 개발할 예정
프론트엔드를 빠르게 시작해야 프론트엔드 개발을 통해 알게 되는 백엔드 에러를 수정할 수 있기 때문에 서둘러 진행할 예정
오늘은 ejs를 이용해 간단한 프론트앤드 예제를 구현함
기본적인 파일 구조와 메인 페이지, 로그인 페이지를 구현함
그리고 axios를 이용해서 백엔드와의 통신해 실제 로그인되는 과정을 구현함
이 프론트엔드 예제를 만드는 이유는 기능 구현이 빨리 끝나서 미리 기본적인 세팅을 진행하고 팀원들에게 이를 전파하기 위해서 구현함
팀원들이 이 내용을 보고 조금이라도 더 빨리 개발에 들어가는 것이 목적임