기본적으로 Node.js는 단일 스레드에서 실행되고, 이벤트 루프는 한번에 하나의 프로세스만 발생한다.

따라서 CPU 집약적인 작업 ( 대규모 데이터 처리, 이미지 변환 등 )이 메인 스레드를 차단 할 수 있다.

 

Worker Thread

CPU 집약적인 작업을 실행할 때 메인 스레드의 성능과 응답성을 유지하기 위해 사용하는 백그라운드 스레드다.

Wokrer는 비동기적으로 실행되고, JavaScript의 싱글 스레드 모델을 보완하여 멀티스레드 환경을 제공한다.

 

1. 독립된 스레드

 Woker는 메인 스레드와 별도의 실행 컨텍스트에서 동작하며, 독립적인 global scope를 가진다.

 

2. 메세지 기반 통신

 메인 스레드와 Worker는 postMessage()와 onMessage를 통해 데이터를 교환한다.

 

woker.js

// Worker 내부 코드
onmessage = function(event) {
  const data = event.data; // 메인 스레드에서 받은 데이터
  const result = data * 2; // 작업 처리
  postMessage(result); // 결과 반환
};

 

main.js

// 메인 스레드 코드
const worker = new Worker('worker.js');

// 메시지 보내기
worker.postMessage(10);

// 메시지 받기
worker.onmessage = function(event) {
  console.log('Worker로부터 받은 결과:', event.data); // 20
};

// Worker 종료
worker.terminate();

 

Javscript에서 제공하는 스케줄링 함수는 다음과 같다.

 

 

setTimeout

let id = setTimeout(()=>{
	내용
}, 밀리초);

 

일정시간 ( = 내가 설정한 밀리초 ) 후에 콜백 함수를 실행해준다.

setTimeout 함수를 실행하면 타이머 식별자를 반환해줘서,

해당 타이머 식별자를 통해 예약되어 있는 setTieout 함수 실행을 취소 해 줄 수 있다.

 

clearTimout

clearTimeout(식별자 id);

 

위처럼 clearTimeout을 통해 취소해 줄 수 있다. 단, 취소가 되더라도 해당 식별자의 값은 null이 되지는 않는다.

 

 

setInterval

let id = setInterval(()=>{
	내용
}, 밀리초);

 

일정시간 ( = 내가 설정한 밀리초 ) 마다 콜백 함수를 실행해준다.

setTimeout과 마찬가지로 함수를 실행하면 식별자를 반환해줘서 취소해 줄 수 있다.

 

clearInterval

clearInterval(식별자 아이디);

 

위처럼 clearInterval을 통해 취소해 줄 수 있다.

 


setInterval, 중첩 setTimeout 비교

setInerval 사용

let i=0;
serInterval(function(){
	func(i++);
}, 100);

 

  • setInterval을 사용하면 func 호출 사이의 지연 간격이 실제 명시한 간격(여기서는 100ms)보다 짧아진다.
  • func을 실행하는 데 소모되는 시간도 지연 간격에 포함시키기 때문
  • func 실행 시간이 명시한 간격보다 길어지면 함수 실행이 종료될 때까지 기다리고 종료되면 바로 다음 호출을 시작한다.
  • 따라서 함수 호출에 걸리는 시간이 매번 delay 밀리 초보다 길면, 모든 함수가 한번에 쉼 없이 연속으로 호출된다.

 

중첩 setTimeout 사용

setTimeout을 재귀적으로 호출하면 setInterval을 쓰는 것처럼 사용할 수 있다.

let i = 1;
setTimeout(function run() {
  func(i++);
  setTimeout(run, 100);
}, 100);

 

  • 중첩 setTimeout을 사용하면 명시한 지연( 여기서는 100ms )이 보장된다.
  • 지연 간격이 보장되는 이유는 이전 함수의 실행이 종료(=완료)된 이후에 다음 함수 호출에 대한 계획이 세워지기 때문

 

express-session은 Express.js에서 세션 ( Session ) 기능을 쉽게 구현하기 위한 미들웨어다.

세션 ( Session )을 사용하기 위해서는 사용자 인증세션 스토리지를 통해 사용자 정보를 저장하고,

세션 정보가 담긴 쿠키를 사용자에게 발급하는 과정이 필요하다.

express-session은 이런 복잡한 과정을 생략해 간단하게 세션 기능을 구현할 수 있도록 도와주는 미들웨어다.

 

express-session 시작해보기

express-session은 세션 ID를 클라이언트에게 발급하고, 이 세션 ID를 통해 서버는 클라이언트의 상태를 추적할 수 있다.

즉, 클라이언트가 세션 ID를 발급받은 후에는 모든 서버 요청마다 세션 ID가 포함된 쿠키를 전달하게 되고, 이로 인해 서버는 클라이언트를 쉽게 식별할 수 있게 된다. ( Session 객체에 세션 ID를 저장하는 방법과 동일한 방법 )

 

예시를 통해 express-session의 동작 방식을 확인해보자

 

설치

# express, express-session를 설치합니다.
yarn add express express-session

 

app.js

// app.js

import express from 'express';
import expressSession from 'express-session';

const app = express();
const PORT = 3019;

app.use(express.json());
app.use(
  expressSession({
    secret: 'express-session-secret-key.', // 세션을 암호화하는 비밀 키를 설정
    resave: false, // 클라이언트의 요청이 올 때마다 세션을 새롭게 저장할 지 설정, 변경사항이 없어도 다시 저장
    saveUninitialized: false, // 세션이 초기화되지 않았을 때 세션을 저장할 지 설정
    cookie: {
      // 세션 쿠키 설정
      maxAge: 1000 * 60 * 60 * 24, // 쿠키의 만료 기간을 1일로 설정합니다.
    },
  }),
);

app.listen(PORT, () => {
  console.log(PORT, '포트로 서버가 열렸어요!');
});

 

express-session은 아래와 같이 전역 미들웨어로 등록된다.

app.use(
  expressSession({
    secret: 'express-session-secret-key.', // 세션을 암호화하는 비밀 키를 설정
    resave: false, // 클라이언트의 요청이 올 때마다 세션을 새롭게 저장할 지 설정, 변경사항이 없어도 다시 저장
    saveUninitialized: false, // 세션이 초기화되지 않았을 때 세션을 저장할 지 설정
    cookie: {
      // 세션 쿠키 설정
      maxAge: 1000 * 60 * 60 * 24, // 쿠키의 만료 기간을 1일로 설정합니다.
    },
  }),
);

각각의 구성요소는 아래와 같은 내용을 담는다.

 

secret

  • 세션 ID를 암호화하기 위한 비밀 키
  • 클라이언트에게 발급할 세션 ID를 암호화하기 위한 비밀 키 정보

resave

  • 클라이언트의 요청( Request )이 들어올 때마다 세션 정보를 다시 저장할 지 설정한다.
  • 변경사항이 없더라도 true로 설정하면, 매번 새로운 세션에 저장된다.

saveUninitialized

  • req.session에 아무런 정보가 저장이 되지 않아도 사용자에게 세션 ID를 발급할지 설정한다.
  • true로 설정하면, 서버에 접속하는 모든 사용자에게 세션 ID가 발급된다.

cookie.maxAge

  • 세션 ID가 저장된 클라이언트의 쿠키 만료 기간을 설정한다.

 

express-session 설정 정보

https://github.com/expressjs/session#options

 

GitHub - expressjs/session: Simple session middleware for Express

Simple session middleware for Express. Contribute to expressjs/session development by creating an account on GitHub.

github.com

 

express-session API 만들기

POST /sessions API를 호출했을 때 전달 받은 userId를 세션에 저장하고, GET /sessions API를 호출했을 때 클라이언트의 세션 정보를 출력하는 API를 구현해보자.

 

POST /sessions API 만들기

/** 세션 등록 API **/
app.post('/sessions', (req, res, next) => {
  const { userId } = req.body;

  // 클라이언트에게 전달받은 userId를 세션에 저장합니다.
  req.session.userId = userId;

  return res.status(200).json({ message: '세션을 설정했습니다.' });
});
  • req-session은 클라이언트의 세션 정보를 관리하는 데 사용되는 객체다.
  • 클라이언트의 요청이 들어오면, req.session.userId에 원하는 정보를 저장한다.
  • userId 대신 다른 이름을 사용하려면 req.session.<원하는 프로퍼티 명>의 형식으로 사용하면 된다.

GET /sessions API 만들기

/** 세션 조회 API **/
app.get('/sessions', (req, res, next) => {
  return res.status(200).json({
    message: '세션을 조회했습니다.',
    session: req.session.userId ?? null, // 세션에 저장된 usrId를 조회합니다.
  });
});
  • express-session은 클라이언트가 전달한 쿠키의 세션 ID를 바탕으로 req.session에서 정보를 조회한다.
  • 만약, 클라이언트가 제공한 세션 ID에 일치하는 세션이 없을 경우, null을 반환한다.

express-session은 클라이언트의 요청 ( Request )에 req.session 정보를 저장할 수 있고, 이후 클라이언트 요청에서 세션에 저장된 정보를 req.session에서 참조해 사용할 수 있게 된다.

이 방식은 기존에 JWT를 이용한 쿠키를 클라이언트에게 전달하는 것보다 더욱 편리하게 구현할 수 있다는 점이 가장 큰 장점이다.

 

express-session의 정보는 서버가 종료되면 사라지는 가장 큰 문제가 존재한다. 이는 세션 정보가 인 메모리방식으로 저장되기 때문인데, 이로 인해 서버가 재시작 되거나 중지될 때 마다 모든 세션 정보가 사라지게 된다.

이런 상황을 방지하기 위해, Redis와 같은 캐시 메모리 데이터베이스를 이용해 세션 정보를 영구적으로 저장해 관리하기도 한다.

 

테스트해보기

1) Insomnia에서 Http Request를 생성하고, POST /sessions API를 호출

userId를 임의로 지정해 서버로 전달

 

세션을 설정했습니다라는 res를 받는다.

 

세션 ID가 저장된 connect.sid 이름을 가지는 Cookie를 전달받는다. ( 이 쿠키는 클라이언트의 세션 ID를 가지고 있다. )

 

2) Insomnia에서 Http Request를 생성하고, GET /sessions API를 호출

 

GET /sessions API를 호출한다.

 

세션 ID를 이용해 서버에 저장된 userId를 확인할 수 있다.

  • GET /sessions API를 호출하면, "세션을 조회했습니다"라는 응답을 확인할 수 있다.
  • 서버는 클라이언트가 전달한 connect.sid 쿠키에 저장된 세션 ID를 바탕으로 서버의 세션 정보를 조회하게 된다.

 

로그 미들웨어 ( Log Middleware )는 클라이언트의 모든 요청 사항을 기록해 서버의 상태를 모니터링하기 위한 미들웨어다.

 

로그 미들웨어는 클라이언트의 요청을 기록해 어플리케이션을 모니터링하고 문제가 발생할 때 빠르게 진단할 수 있다.

또한, 로그 데이터는 사용자의 행동을 분석하는 등 데이터 분석 작업에도 활용할 수 있다.

규모가 큰 프로젝트를 진행하게 되면, 화면에 표시되는 모든 로그를 일일이 확인하는 것은 불가능에 가깝다.

이런 경우를 대비해 로그 기능을 지원하는 morgan, winston과 같은 라이브러리를 사용하거나 AWS CloudWatch, Datadog와 같은 외부 모니터링 솔루션 서비스를 이용해 로그를 수집하거나 관리할 수 있다.

 

Datadog와 같은 서비스를 이용해 로그 수집, 로그 분석과 같은 서비스를 빠르게 구현할 수 있다.

 

아래 예시를 통해 클라이언트의 요청을 터미널에 기록하는 간단한 로그 미들웨어를 구현해보자.

우선 winstion 라이브러리를 설치하는것으로 시작!

 

# yarn을 이용해 winston을 설치합니다.
yarn add winston

 

log.middleware

// src/middlewares/log.middleware.js

import winston from 'winston';

const logger = winston.createLogger({
  level: 'info', // 로그 레벨을 'info'로 설정합니다.
  format: winston.format.json(), // 로그 포맷을 JSON 형식으로 설정합니다.
  transports: [
    new winston.transports.Console(), // 로그를 콘솔에 출력합니다.
  ],
});

export default function (req, res, next) {
  // 클라이언트의 요청이 시작된 시간을 기록합니다.
  const start = new Date().getTime();

  // 응답이 완료되면 로그를 기록합니다.
  res.on('finish', () => {
    const duration = new Date().getTime() - start;
    logger.info(
      `Method: ${req.method}, URL: ${req.url}, Status: ${res.statusCode}, Duration: ${duration}ms`,
    );
  });

  next();
}

 

app.js

// src/app.js

import express from 'express';
import cookieParser from 'cookie-parser';
import LogMiddleware from './middlewares/log.middleware.js';
import UsersRouter from './routes/users.router.js';

const app = express();
const PORT = 3018;

app.use(LogMiddleware);
app.use(express.json());
app.use(cookieParser());
app.use('/api', [UsersRouter]);

app.listen(PORT, () => {
  console.log(PORT, '포트로 서버가 열렸어요!');
});

 

로그 레벨 ( level )은 로그의 중요도를 나타낸다. 

단순히 클라이언트의 요청 사항을 기록하기 위해 "Info" 레벨을 사용하지만, "error", "warn", "debug" 등 다양한 로그레벨이 있으며, 특정 상황에 따라 출력하는 레벨을 다르게 구현할 수 있다.

 

로그 미들웨어는 클라이언트의 요청이 발생했을 때, 가장 먼저 실행되어야 하는 미들웨어다.

따라서 app.use를 이용한 전역 미들웨어 중에서 가장 최상단에 위치한다.

 

에러 처리 미들웨어

에러 처리 미들웨어 ( Error Handling Middleware )는 Express.js에서 발생한 에러를 통합적으로 처리하기 위한 미들웨어다

 

error-handling.middleware

// src/middlewares/error-handling.middleware.js

export default function (err, req, res, next) {
  // 에러를 출력합니다.
  console.error(err);

  // 클라이언트에게 에러 메시지를 전달합니다.
  res.status(500).json({ errorMessage: '서버 내부 에러가 발생했습니다.' });
}

 

user.router.js

// src/routes/users.router.js

/** 사용자 회원가입 API 에러 처리 미들웨어 **/
router.post('/sign-up', async (req, res, next) => {
  try {
    const { email, password, name, age, gender, profileImage } = req.body;
    const isExistUser = await prisma.users.findFirst({
      where: {
        email,
      },
    });

    if (isExistUser) {
      return res.status(409).json({ message: '이미 존재하는 이메일입니다.' });
    }

    // 사용자 비밀번호를 암호화합니다.
    const hashedPassword = await bcrypt.hash(password, 10);

    // Users 테이블에 사용자를 추가합니다.
    const user = await prisma.users.create({
      data: {
        email,
        password: hashedPassword, // 암호화된 비밀번호를 저장합니다.
      },
    });

    // UserInfos 테이블에 사용자 정보를 추가합니다.
    const userInfo = await prisma.userInfos.create({
      data: {
        userId: user.userId, // 생성한 유저의 userId를 바탕으로 사용자 정보를 생성합니다.
        name,
        age,
        gender: gender.toUpperCase(), // 성별을 대문자로 변환합니다.
        profileImage,
      },
    });

    return res.status(201).json({ message: '회원가입이 완료되었습니다.' });
  } catch (err) {
    next(err);
  }
});

 

try catch 구문으로 묶고 error가 나면 next로 error-handling.middleware로 에러 전달

 

 

app.js

// src/app.js

import express from 'express';
import cookieParser from 'cookie-parser';
import LogMiddleware from './middlewares/log.middleware.js';
import ErrorHandlingMiddleware from './middlewares/error-handling.middleware.js';
import UsersRouter from './routes/users.router.js';

const app = express();
const PORT = 3018;

app.use(LogMiddleware);
app.use(express.json());
app.use(cookieParser());
app.use('/api', [UsersRouter]);
app.use(ErrorHandlingMiddleware);

app.listen(PORT, () => {
  console.log(PORT, '포트로 서버가 열렸어요!');
});

 

에러 처리 미들웨어는 클라이언트의 요청이 실패했을 때, 가장 마지막에 실행되어야 하는 미들웨어다.

따라서 app.use를 이용한 전역 미들웨어 중 가장 최하단에 위치한다.

 

에러 처리 미들웨어는 모든 에러를 관리하는 미들웨어, 서버 내부에서 발생한 에러를 상세하게 클라이언트에게 제공한다면 악의적인 사용자에게 공격의 표적이 될 수 있다. 

그러므로, 에러 처리 미들웨어에서는 "서버에서 에러가 발생했습니다."와 같은 추상적인 내용을 클라이언트에게 전달하도록 구현해야한다.

 

에러처리 공식 문서 

https://expressjs.com/ko/guide/error-handling.html

 

Express - Node.js web application framework

 

expressjs.com

 

 

 

delete 뒤에 객체의 속성 값을 입력하면 해당 속성을 제거한다.

 

let test = {a:0, b:1, c:2};
delete test.a;

console.log(test); // { b: 1, c: 2 }

 

https://developer.mozilla.org/ko/docs/Web/JavaScript/Reference/Operators/delete

 

delete 연산자 - JavaScript | MDN

**delete**는 객체의 속성을 제거합니다. 속성의 값이 객체이고 더 이상 그 객체에 대한 참조가 없다면, 해당 객체는 결국 자동으로 메모리에서 해제됩니다.

developer.mozilla.org

 

.env 파일에 설정한 환경변수 값을 읽어 오고 싶을 경우 dotenv 패키지를 설치해 읽어오면 편하다.

 

yarn add dotenv

 

위 명령어를 이용해 dotenv 패키지를 설치한다.

 

.env

ACCESS_TOKEN_SECRET_KEY="YamSaeng52"
REFRESH_TOKEN_SECRET_KEY="JungSungWon52"

 

 

.env파일에 위와 같이 선언한 변수 값을 읽어오고 싶을때 아래와 같이 해주면 된다.

import dotenv from "dotenv";

dotenv.config();

const accessTokenSecretKey = process.env.ACCESS_TOKEN_SECRET_KEY;
const refreshTokenSecretKey = process.env.REFRESH_TOKEN_SECRET_KEY;

console.log(accessTokenSecretKey);
console.log(refreshTokenSecretKey);

 

출력 모습

Access Token과 Refresh Token을 발급해보자.

 

# yarn을 이용해 프로젝트를 초기화합니다.
yarn init -y

# express, jsonwebtoken, cookie-parser 패키지를 설치합니다.
yarn add express jsonwebtoken cookie-parser
  • package.json에 "type":"module" 추가

 

app.js ( 기본적인 틀 )

// app.js

import express from 'express';
import jwt from 'jsonwebtoken';
import cookieParser from 'cookie-parser';

const app = express();
const PORT = 3019;

// 비밀 키는 외부에 노출되면 안되겠죠? 그렇기 때문에, .env 파일을 이용해 비밀 키를 관리해야함
const ACCESS_TOKEN_SECRET_KEY = `HangHae99`; // Access Token의 비밀 키를 정의한다.
const REFRESH_TOKEN_SECRET_KEY = `Sparta`; // Refresh Token의 비밀 키를 정의한다.

app.use(express.json());
app.use(cookieParser());

app.get('/', (req, res) => {
  return res.status(200).send('Hello Token!');
});

app.listen(PORT, () => {
  console.log(PORT, '포트로 서버가 열렸어요!');
});

 

 

Refresh Token과 Access Token을 발급하는 API 만들어보기

POST /tokens 의 주소를 갖는 API 생성

// app.js

import express from 'express';
import jwt from 'jsonwebtoken';
import cookieParser from 'cookie-parser';

const app = express();
const PORT = 3019;

// 비밀 키는 외부에 노출되면 안되겠죠? 그렇기 때문에, .env 파일을 이용해 비밀 키를 관리해야함
const ACCESS_TOKEN_SECRET_KEY = `HangHae99`; // Access Token의 비밀 키를 정의한다.
const REFRESH_TOKEN_SECRET_KEY = `Sparta`; // Refresh Token의 비밀 키를 정의한다.

app.use(express.json());
app.use(cookieParser());

app.get('/', (req, res) => {
    return res.status(200).send('Hello Token!');
});

let tokenStorage = {}; // Refresh Token을 저장할 객체

/** Access Token, Refresh Token 발급 API **/
app.post('/tokens', (req, res) => {
    // id 전달
    const { id } = req.body;

    // accessToken 발급
    const accessToken = createAccessToken(id);
    // refreshToken 발급
    const refreshToken = createRefreshToken(id);

    // Refresh Token을 가지고 해당 유저의 정보를 서버에 저장합니다.
    tokenStorage[refreshToken] = {
        id: id, // 사용자에게 전달받은 ID를 저장합니다.
        ip: req.ip, // 사용자의 IP 정보를 저장합니다.

        // 사용자의 User Agent 정보를 저장합니다.
        // 특정 클라이언트가 어떤 방식으로 서버에 요청했는지를 알수 있는 정보
        // 예를 들어 firefox를 이용했는지, chrome을 이용했는지, 모바일로 chrome을 실행했는지 등        
        userAgent: req.headers['user-agent'], 
    };

    //console.log(tokenStorage);

    res.cookie('accessToken', accessToken); // Access Token을 Cookie에 전달한다.
    res.cookie('refreshToken', refreshToken); // Refresh Token을 Cookie에 전달한다.

    return res
        .status(200)
        .json({ message: 'Token이 정상적으로 발급되었습니다.' });
});

// Access Token을 생성하는 함수
function createAccessToken(id) {
    const accessToken = jwt.sign(
        { id: id }, // JWT 데이터
        ACCESS_TOKEN_SECRET_KEY, // Access Token의 비밀 키
        { expiresIn: '10s' }, // Access Token이 10초 뒤에 만료되도록 설정한다.
    );

    return accessToken;
}

// Refresh Token을 생성하는 함수
function createRefreshToken(id) {
    const refreshToken = jwt.sign(
        { id: id }, // JWT 데이터
        REFRESH_TOKEN_SECRET_KEY, // Refresh Token의 비밀 키
        { expiresIn: '7d' }, // Refresh Token이 7일 뒤에 만료되도록 설정한다.
    );

    return refreshToken;
}

app.listen(PORT, () => {
    console.log(PORT, '포트로 서버가 열렸어요!');
});

 


 

Refresh Token의 정보를 어디서 관리해야할까?

위 예시에서는 tokenStorage 라는 변수에서 관리했지만, 이는 인 메모리 방식을 사용하기 때문에 서버가 재시작 또는 종료될 경우 모든 정보가 사라지게 된다.

따라서 별도의 테이블에서 Refresh Token을 저장하고 관리해야한다. 

이렇게 하면, Refresh Token 검증 작업을 MySQL과 같은 데이터베이스를 조회함과 동시에 함께 처리할 수 있다.

 

Refresh Tokens Table

Name 타입 ( Type ) NULL default 비고
tokenId ( PK ) INTEGER NOT NULL AUTO_INCREMENT 토큰의 기본 키
userId ( FK ) INTEGER NOT NULL   사용자의 기본 키
token STRING NOT NULL   리프레시 토큰
expiresAt DATETIME NOT NULL   만료 날짜
createdAt DATETIME NOT NULL 현재 시간 생성 날짜
  • 이 외에도 ip 또는 user-Agent와 같은 정보를 추가할 수 있다.

 

Access Token 검증 API

서버에서 발급받은 Access Token을 검증하는 GET /tokens/validate API를 만들어보자.

 

// app. js

...

/** 엑세스 토큰 검증 API **/
app.get('/tokens/validate', (req, res) => {
  const accessToken = req.cookies.accessToken;

  if (!accessToken) {
    return res
      .status(400)
      .json({ errorMessage: 'Access Token이 존재하지 않습니다.' });
  }

  const payload = validateToken(accessToken, ACCESS_TOKEN_SECRET_KEY);
  if (!payload) {
    return res
      .status(401)
      .json({ errorMessage: 'Access Token이 유효하지 않습니다.' });
  }

  const { id } = payload;
  return res.json({
    message: `${id}의 Payload를 가진 Token이 성공적으로 인증되었습니다.`,
  });
});

// Token을 검증하고 Payload를 반환합니다.
function validateToken(token, secretKey) {
  try {
    const payload = jwt.verify(token, secretKey);
    return payload;
  } catch (error) {
    return null;
  }
}

 

ValidateToken 함수 추가 설명

ValidaeToken 함수는 제공된 토큰이 유효한지 여부를 검증하는 역할을 담당한다.

  • secretkey를 전달받아, 서버에서 검증할 비밀 키를 설정한다.
  • Access Token 이나 Refresh Token이 우리가 발급한 것인지 검증한다.
  • Access Token 이나 Refresh Token의 만료 여부를 검증한다.

사용자가 Cookie를 전달할 때, Access Token이 없다면 에러가 발생한다.

  • { "message": "Access Token이 존재하지 않습니다." }

사용자가 전달한 Access Token이 유효하지 않을 경우 에러가 발생한다.

  • { "message": "Access Token이 유효하지 않습니다." }

 

사용자가 Access Token을 이용해 API를 호출하면, 해당 토큰의 상태에 따라 적절한 응답 ( Response )이 반환되는 것을 확인 할 수 있다.

Access Token이 정상적으로 인증되면, 응답에서 페이로드 값을 확인할 수 있다.

 


 

Refresh Token으로 Access Token을 재발급하는 API 

서버에서 발급받은 Access Token을 검증하는 POST /tokens/refresh API를 만들어보자.

// app. js

...

/** 리프레시 토큰 검증 API **/
app.post('/tokens/refresh', (req, res) => {
  const refreshToken = req.cookies.refreshToken;

  if (!refreshToken)
    return res
      .status(400)
      .json({ errorMessage: 'Refresh Token이 존재하지 않습니다.' });

  const payload = validateToken(refreshToken, REFRESH_TOKEN_SECRET_KEY);
  if (!payload) {
    return res
      .status(401)
      .json({ errorMessage: 'Refresh Token이 유효하지 않습니다.' });
  }

  const userInfo = tokenStorage[refreshToken];
  if (!userInfo)
    return res.status(419).json({
      errorMessage: 'Refresh Token의 정보가 서버에 존재하지 않습니다.',
    });

  const newAccessToken = createAccessToken(userInfo.id);

  res.cookie('accessToken', newAccessToken);
  return res.json({ message: 'Access Token을 새롭게 발급하였습니다.' });
});

// Token을 검증하고 Payload를 반환합니다.
function validateToken(token, secretKey) {
  try {
    const payload = jwt.verify(token, secretKey);
    return payload;
  } catch (error) {
    return null;
  }
}

 

사용자가 Cookie를 전달할 때, Refresh Token이 없다면 에러가 발생한다.

  • { "message": "Refresh Token이 존재하지 않습니다." }

사용자가 전달한 Refresh Token이 유효하지 않을 경우 에러가 발생한다.

  • { "message": "Refresh Token이 유효하지 않습니다." }

Refresh Token이 유효하지만, 서버에 해당 토큰 정보가 없을 경우 에러가 발생한다.

  • { "message": "Refresh Token의 정보가 서버에 존재하지 않습니다." }

사용자가 Refresh Token을 이용해 API를 호출하면, 해당 토큰의 상태에 따라 적절한 응답 ( Response )이 반환되는 것을 확인할 수 있다.

만약, Refresh Token이 정상적으로 인증되면, 사용자에게 Access Token을 담은 Cookie를 전달하는 것을 확인할 수 있다.

 


 

 

일반적으로, 사용자의 비밀번호를 데이터베이스에 저장할 때, 보안을 위해 비밀번호를 평문으로 저장하지 않고 암호화해 저장한다.

 

bcrypt 모듈은 입력받은 데이터를 특정 암호화 알고리즘을 이용해 암호화 및 검증을 도와주는 모듈이다.

aaaa4321 이라는 비밀번호를 bcrypt를 이용해 암호화하면 특정한 문자열로 변환된다.

이 변환된 문자열은 단방향 암호화되어 원래의 비밀번호 ( aaaa4321 )로 복구할 수 없게 된다.

하지만, 입력된 비밀번호가 암호화된 문자열과 일치하는지를 비교할 수 있다.

이를 통해 사용자의 비밀번호가 올바른지, 아닌지 검증할 수 있게 되는것이다.

 

bcrypt 설치

# yarn을 이용해 bcrypt를 설치합니다.
yarn add bcrypt

 

bcrypt 암호화

import bcrypt from 'bcrypt';

const password = 'Sparta'; // 사용자의 비밀번호
const saltRounds = 10; // salt를 얼마나 복잡하게 만들지 결정합니다.

// 'hashedPassword'는 암호화된 비밀번호 입니다.
const hashedPassword = await bcrypt.hash(password, saltRounds);

console.log(hashedPassword); //$2b$10$OOziCKNP/dH1jd.Wvc3JluZVm7H8WXR8oUmxUQ/cfdizQOLjCXoXa

 

 

bcrypt 복호화

import bcrypt from 'bcrypt';

const password = 'Sparta'; // 사용자가 입력한 비밀번호
const hashed = '$2b$10$OOziCKNP/dH1jd.Wvc3JluZVm7H8WXR8oUmxUQ/cfdizQOLjCXoXa'; // DB에서 가져온 암호화된 비밀번호

// 'result'는 비밀번호가 일치하면 'true' 아니면 'false'
const result = await bcrypt.compare(password, hashed);

console.log(result); // true

// 비밀번호가 일치하지 않다면, 'false'
const failedResult = await bcrypt.compare('FailedPassword', hashed);

console.log(failedResult); // false

 

Express.js를 이용한 게시판 프로젝트를 만들기 전에, 우선 app.js에 기본적인 토대를 만들고 간단한 API를 구현해보자.

 

모든 Router에서 Prisma를 사용할 수 있도록 utils/prisma/index.js 파일을 생성한다.

 

app.js 초기화

// src/app.js

import express from 'express';
import cookieParser from 'cookie-parser';

const app = express();
const PORT = 3018;

app.use(express.json());
app.use(cookieParser());

app.listen(PORT, () => {
  console.log(PORT, '포트로 서버가 열렸어요!');
});

 

Primsa 초기화

// src/utils/prisma/index.js

import { PrismaClient } from '@prisma/client';

export const prisma = new PrismaClient({
  // Prisma를 이용해 데이터베이스를 접근할 때, SQL을 출력해줍니다.
  log: ['query', 'info', 'warn', 'error'],

  // 에러 메시지를 평문이 아닌, 개발자가 읽기 쉬운 형태로 출력해줍니다.
  errorFormat: 'pretty',
}); // PrismaClient 인스턴스를 생성합니다.

 

프로젝트 구성

.
├── package.json
├── prisma
│   └── schema.prisma
├── src
│   ├── app.js
│   └── utils
│       └── prisma
│           └── index.js
└── yarn.lock

 

회원가입 API

회원가입 API 비즈니스 로직

  1. email, password, name, age, gender, profileImage를 body로 전달받는다.
  2. 동일한 email을 가진 사용자가 있는지 확인한다.
  3. Users 테이블에 email, password를 이용해 사용자를 생성한다.
  4. UserInfos 테이블에 name, age, gender, profileImage를 이용해 사용자 정보를 생성한다.

회원가입 API사용자사용자 정보가 1:1 관계를 가진 것을 바탕으로 비즈니스 로직이 구현된다.

사용자 정보사용자가 존재하지 않을 경우 생성될 수 없으므로, 전달받은 인자값을 이용해 사용자사용자 정보 순서대로 회원가입을 진행해야한다.

routes/users.router.js 파일을 생성하고, UsersRouter를 app.js 전역 미들웨어에 등록 후 회원가입 API를 구현해보자.

 

UsersRouter를 등록한 app.js

// app.js

import express from 'express';
import cookieParser from 'cookie-parser';
import UsersRouter from './routes/users.router.js';

const app = express();
const PORT = 3018;

app.use(express.json());
app.use(cookieParser());
app.use('/api', [UsersRouter]);

app.listen(PORT, () => {
  console.log(PORT, '포트로 서버가 열렸어요!');
});

 

회원가입 API

// src/routes/users.router.js

import express from 'express';
import { prisma } from '../utils/prisma/index.js';

const router = express.Router();

/** 사용자 회원가입 API **/
router.post('/sign-up', async (req, res, next) => {
  const { email, password, name, age, gender, profileImage } = req.body;
  const isExistUser = await prisma.users.findFirst({
    where: {
      email,
    },
  });

  if (isExistUser) {
    return res.status(409).json({ message: '이미 존재하는 이메일입니다.' });
  }

  // Users 테이블에 사용자를 추가합니다.
  const user = await prisma.users.create({
    data: { email, password },
  });
  // UserInfos 테이블에 사용자 정보를 추가합니다.
  const userInfo = await prisma.userInfos.create({
    data: {
      userId: user.userId, // 생성한 유저의 userId를 바탕으로 사용자 정보를 생성합니다.
      name,
      age,
      gender: gender.toUpperCase(), // 성별을 대문자로 변환합니다.
      profileImage,
    },
  });

  return res.status(201).json({ message: '회원가입이 완료되었습니다.' });
});

export default router;

 

사용자만 생성되고, 사용자 정보 생성에 실패할 경우

현재는 Users 테이블에 사용자를 생성하고, UserInfos 테이블에 userId를 이용해 사용자의 정보를 저장한다.

만약, 사용자 정보 저장에 실패하게 될 경우 Users 테이블에는 사용자가 존재하지만, UserInfos 테이블의 사용자 정보는 저장되지 않는 상태로 남아있는다.

이런 문제를 해결하기 위해 MySQL과 같은 RDBMS에서는 트랜잭션 ( Transaction )이라는 개념이 도입됐는데, Prisma 또한 동일하게 사용할 수 있기 때문에 다음 글에서 작성해보도록 하겠다.

 

bcrypt ( 단방향 암호화, 복호화 )

2024.09.09 - [Javascript] - [Javascript] bcrypt ( 단방향 암호화, 복호화 )

 

[Javascript] bcrypt ( 단방향 암호화, 복호화 )

일반적으로, 사용자의 비밀번호를 데이터베이스에 저장할 때, 보안을 위해 비밀번호를 평문으로 저장하지 않고 암호화해 저장한다. bcrypt 모듈은 입력받은 데이터를 특정 암호화 알고리즘을 이

program-yam.tistory.com

 

회원가입 API Bcrypt 리팩토링

// src/routes/users.router.js

import bcrypt from 'bcrypt';

/** 사용자 회원가입 API 리팩토링**/
router.post('/sign-up', async (req, res, next) => {
  const { email, password, name, age, gender, profileImage } = req.body;
  const isExistUser = await prisma.users.findFirst({
    where: {
      email,
    },
  });

  if (isExistUser) {
    return res.status(409).json({ message: '이미 존재하는 이메일입니다.' });
  }

  // 사용자 비밀번호를 암호화합니다.
  const hashedPassword = await bcrypt.hash(password, 10);

  // Users 테이블에 사용자를 추가합니다.
  const user = await prisma.users.create({
    data: {
      email,
      password: hashedPassword, // 암호화된 비밀번호를 저장합니다.
    },
  });

  // UserInfos 테이블에 사용자 정보를 추가합니다.
  const userInfo = await prisma.userInfos.create({
    data: {
      userId: user.userId, // 생성한 유저의 userId를 바탕으로 사용자 정보를 생성합니다.
      name,
      age,
      gender: gender.toUpperCase(), // 성별을 대문자로 변환합니다.
      profileImage,
    },
  });

  return res.status(201).json({ message: '회원가입이 완료되었습니다.' });
});

 

회원가입 API 리팩토링이 완료되었으면, API Client를 이용해 해당 API를 호출해보자.

Users 테이블의 password 항목이 이전과 다르게 암호화된 값으로 작성되어 있을 것이다.

 

암호화된 사용자의 비밀번호


 

로그인 API

로그인 API는 bcrypt로 암호화된 사용자 비밀번호를 인증하는 방식이다.

클라이언트가 제공한 비밀번호와 데이터베이스에 저장된 암호화된 비밀번호를 검증해 구현해보자.

 

로그인 API 비즈니스 로직

  1. email, password를 body로 전달받는다.
  2. 전달 받은 email에 해당하는 사용자가 있는지 확인한다.
  3. 전달 받은 password와 데이터베이스의 저장된 password를 bcrypt를 이용해 검증한다.
  4. 로그인에 성공하면, 사용자에게 JWT를 발급한다.

로그인 API는 클라이언트가 전달한 정보를 바탕으로 사용자를 확인한다. 클라이언트로부터 받은 email과 password를 사용해 데이터베이스에 저장된 사용자를 검증하고, 검증에 성공하면 JWT를 담고있는 쿠키를 생성해 반환한다.

클라이언트는 로그인 이후 요청부터 쿠키를 함께 보내면, 이를 통해 서버에서는 해당 사용자를 식별하고, 인증 ( Authentication ), 인가 ( Authorization ) 과정을 거친다.

 

로그인 API

// src/routes/users.route.js

import jwt from 'jsonwebtoken';

/** 로그인 API **/
router.post('/sign-in', async (req, res, next) => {
  const { email, password } = req.body;
  const user = await prisma.users.findFirst({ where: { email } });

  if (!user)
    return res.status(401).json({ message: '존재하지 않는 이메일입니다.' });
  // 입력받은 사용자의 비밀번호와 데이터베이스에 저장된 비밀번호를 비교합니다.
  else if (!(await bcrypt.compare(password, user.password)))
    return res.status(401).json({ message: '비밀번호가 일치하지 않습니다.' });

  // 로그인에 성공하면, 사용자의 userId를 바탕으로 토큰을 생성합니다.
  const token = jwt.sign(
    {
      userId: user.userId,
    },
    'custom-secret-key',
  );

  // authotization 쿠키에 Berer 토큰 형식으로 JWT를 저장합니다.
  res.cookie('authorization', `Bearer ${token}`);
  return res.status(200).json({ message: '로그인 성공' });
});

 

발급한 쿠키의 모습

 


사용자 인증 미들웨어

사용자 인증 미들웨어는 클라이언트로 부터 전달받은 쿠키를 검증하는 작업을 수행한다.

클라이언트가 제공한 쿠키에 담겨있는 JWT를 이용해 사용자를 조회하도록 구현해보자.

 

사용자 인증 미들웨어 비즈니스 로직

  1. 클라이언트로 부터 쿠키 ( Cookie )를 전달받는다.
  2. 쿠키 ( Cookie )가 Bearer 토큰 형식인지 확인한다.
  3. 서버에서 발급한 JWT가 맞는지 검증한다.
  4. JWT의 userId를 이용해 사용자를 조회한다.
  5. req.user에 조회된 사용자 정보를 할당한다.
  6. 다음 미들웨어를 실행한다.

사용자 인증 미들웨어는 클라이언트가 전달한 쿠키를 바탕으로 사용자를 검증한다. 이 과정에서 토큰이 만료되진 않았는지, 토큰의 형식은 일치하는지, 서버가 발급한 토큰이 맞는지 등 다양한 검증을 수행해 사용자의 권한을 확인한다.

이렇게, JWT를 통해 사용자를 인증하는 것을 인증 ( Authentication )이라고 부른다.

클라이언트는 인증 과정을 전부 통과하였을 때에 로그인된 사용자만 사용할 수 있는 게시글 작성 API, 사용자 정보 조회 API와 같은 권한이 필요한 API를 사용할 수 있게 된다. 이 사용자의 권한을 확인하는 과정을 인가 ( Authorization ) 과정이라고 부른다. ( 사용자 인증 미들웨어는 인증 기능만 제공한다. 이후 관리자 권한, 등급별 권한이 필요하면, 별도의 인가 과정을 추가해야한다 )

 

사용자 인증 미들웨어

// src/middlewares/auth.middleware.js

import jwt from 'jsonwebtoken';
import { prisma } from '../utils/prisma/index.js';

export default async function (req, res, next) {
  try {
    const { authorization } = req.cookies;
    if (!authorization) throw new Error('토큰이 존재하지 않습니다.');

	// Bearer이 tokenType에 들어가고, %20을 건너 뛴 후에 이후 내용이 token으로 들어간다.
    const [tokenType, token] = authorization.split(' ');

    if (tokenType !== 'Bearer')
      throw new Error('토큰 타입이 일치하지 않습니다.');

    const decodedToken = jwt.verify(token, 'custom-secret-key');
    const userId = decodedToken.userId;

    const user = await prisma.users.findFirst({
      where: { userId: +userId },
    });
    if (!user) {
      res.clearCookie('authorization');
      throw new Error('토큰 사용자가 존재하지 않습니다.');
    }

    // req.user에 사용자 정보를 저장합니다.
    req.user = user;

    next();
  } catch (error) {
    res.clearCookie('authorization');

    // 토큰이 만료되었거나, 조작되었을 때, 에러 메시지를 다르게 출력합니다.
    switch (error.name) {
      case 'TokenExpiredError':
        return res.status(401).json({ message: '토큰이 만료되었습니다.' });
      case 'JsonWebTokenError':
        return res.status(401).json({ message: '토큰이 조작되었습니다.' });
      default:
        return res
          .status(401)
          .json({ message: error.message ?? '비정상적인 요청입니다.' });
    }
  }
}

 

사용자 정보 조회 API

사용자 정보 조회 API는 일반적으로 다른 사용자의 정보를 조회하기 위해 사용한다.

 

사용자 정보 조회 API 비즈니스 로직

  1. 클라이언트가 로그인된 사용자인지 검증한다.
  2. 사용자를 조회할 때, 1:1 관계를 맺고 있는 Users와 UserInfos 테이블을 조회한다.
  3. 조회한 사용자의 상세한 정보를 클라이언트에게 반환한다.

사용자 정보 조회 API는 단순히 Users 테이블 하나만 조회를 하는 것이 아니라, UserInfos 테이블을 함께 조회한다.

그렇기 때문에, 각각의 테이블을 1번씩 조회하게 되어 총 2번의 조회를 하게되는 문제가 발생한다.

이런 문제를 해결하기 위해 Prisma에서는 중첩 select 라는 문법을 제공한다.

 

사용자 정보 조회 API

// src/routes/users.route.js

/** 사용자 조회 API **/
router.get('/users', authMiddleware, async (req, res, next) => {
  const { userId } = req.user;

  const user = await prisma.users.findFirst({
    where: { userId: +userId },
    select: {
      userId: true,
      email: true,
      createdAt: true,
      updatedAt: true,
      userInfos: {
        // 1:1 관계를 맺고있는 UserInfos 테이블을 조회합니다.
        select: {
          name: true,
          age: true,
          gender: true,
          profileImage: true,
        },
      },
    },
  });

  return res.status(200).json({ data: user });
});

 

Prisma 에서 연관 관계 테이블을 조회하는 방법

Prisma의 select 문법은 Query 메서드의 옵션으로 사용할 수 있다.

await prisma.users.findFirst({
  where: { userId: +userId },
  select: {
    userId: true,
    email: true,
    createdAt: true,
    updatedAt: true,
    userInfos: {
      // 1:1 관계를 맺고있는 UserInfos 테이블을 조회합니다.
      select: {
        name: true,
        age: true,
        gender: true,
        profileImage: true,
      },
    },
  },
});
  • select 내에 또다른 select가 있는데, 이를 중첩 select 문법이라 한다.
  • 중첩 select 는 SQL의 JOIN과 동일한 역할을 수행한다.
  • 중첩 select 문법을 사용하기 위해서는 Prisma model에서 @relation() 과 같이 관계가 설정이 되어야 한다.
  • @relation() 으로 Prisma는 현재 모델에서 참조하는 외래 키를 인식하고, SQL을 생성할 수 있게 된다.
  • 현재 테이블과 연관된 테이블의 모든 컬럼을 조회하고 싶으면, include 문법으로도 조회할 수 있다.

중첩 select 문법과 include 문법에 대한 자세한 정보 : https://www.prisma.io/docs/concepts/components/prisma-client/relation-queries

 

Relation queries (Concepts) | Prisma Documentation

Prisma Client provides convenient queries for working with relations, such as a fluent API, nested writes (transactions), nested reads and relation filters.

www.prisma.io

 

Prisma를 이용해 게시판을 개발해보자

 

[게시판 프로젝트] 시작하기

[게시판 프로젝트] 설계

게시판 프로젝트 ERD

 

위 그림을 바탕으로 각 테이블간의 관계 및 요구사항을 정리해보자.

 

게시판 프로젝트 테이블 관계 및 요구사항 정리

사용자( Users )는 1개의 사용자 정보 ( UserInfo )를 가지고 있다.

  • Users 테이블과 UserInfo 테이블은 1:1 관계를 가지고 있다.

사용자( Users )는 여러개의 게시글 ( Posts )을 등록할 수 있다.

  • Users 테이블과 Posts 테이블을 1:N 관계를 가지고 있다.

사용자( Users )는 여러개의 댓글 ( Commnets )을 작성할 수 있다.

  • Users 테이블과 Comments 테이블은 1:N 관계를 가지고 있다.

하나의 게시글 ( Posts )은 여러개의 댓글 ( Comments )이 작성될 수 있다.

  • Posts 테이블과 Comments 테이블은 1:N 관계를 가지고 있다.

 


 

Community-Hub 프로젝트 생성

[게시판 프로젝트] 라이브러리 설치

# 프로젝트를 초기화합니다.
yarn init -y

# 라이브러리를 설치합니다.
yarn add express prisma @prisma/client cookie-parser jsonwebtoken

# nodemon 라이브러리를 DevDependency로 설치합니다.
yarn add -D nodemon

# 설치한 Prisma를 초기화 하여, Prisma를 사용할 수 있는 구조를 생성합니다.
npx prisma init
  • package.json에 "type":"module"도 추가 ( ES6 문법 사용하기 위함 )

 

프로젝트 구조 ( 게시판 프로젝트 )

내 프로젝트 폴더 이름
├── .env
├── .gitignore
├── package.json
├── yarn.lock
├── prisma
│    └── schema.prisma
└── src
  └── app.js

 

SQL로 미리 확인해보기

CREATE TABLE Users
(
    userId    INTEGER             NOT NULL AUTO_INCREMENT PRIMARY KEY,
    email     VARCHAR(191) UNIQUE NOT NULL,
    password  VARCHAR(191)        NOT NULL,
    createdAt DATETIME(3)         NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
    updatedAt DATETIME(3)         NOT NULL DEFAULT CURRENT_TIMESTAMP(3)
);


CREATE TABLE UserInfos
(
    userInfoId   INTEGER        NOT NULL AUTO_INCREMENT PRIMARY KEY,
    userId       INTEGER UNIQUE NOT NULL, -- 1:1 관계 이므로 UNIQUE 조건을 삽입합니다.
    name         VARCHAR(191)   NOT NULL,
    age          INTEGER        NOT NULL,
    gender       VARCHAR(191)   NOT NULL,
    profileImage VARCHAR(191)   NULL,
    createdAt    DATETIME(3)
                                NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
    updatedAt    DATETIME(3)    NOT NULL DEFAULT CURRENT_TIMESTAMP(3)
);

ALTER TABLE UserInfos
    ADD CONSTRAINT FK_UserInfos_Users
        FOREIGN KEY (userId) REFERENCES Users (userId) ON DELETE CASCADE;

CREATE TABLE Posts
(
    postId    INTEGER      NOT NULL AUTO_INCREMENT PRIMARY KEY,
    userId    INTEGER      NOT NULL,
    title     VARCHAR(191) NOT NULL,
    content   VARCHAR(191) NOT NULL,
    createdAt DATETIME(3)  NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
    updatedAt DATETIME(3)  NOT NULL DEFAULT CURRENT_TIMESTAMP(3)
);

ALTER TABLE Posts
    ADD CONSTRAINT FK_Posts_Users
        FOREIGN KEY (userId) REFERENCES Users (userId) ON DELETE CASCADE;


CREATE TABLE Comments
(
    commentId INTEGER      NOT NULL AUTO_INCREMENT PRIMARY KEY,
    userId    INTEGER      NOT NULL,
    postId    INTEGER      NOT NULL,
    content   VARCHAR(191) NOT NULL,
    createdAt DATETIME(3)  NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
    updatedAt DATETIME(3)  NOT NULL DEFAULT CURRENT_TIMESTAMP(3)
);

ALTER TABLE Comments
    ADD CONSTRAINT FK_Comments_Posts
        FOREIGN KEY (postId) REFERENCES Posts (postId) ON DELETE CASCADE;

ALTER TABLE Comments
    ADD CONSTRAINT FK_Comments_Users
        FOREIGN KEY (userId) REFERENCES Users (userId) ON DELETE CASCADE;

 

 

[게시판 프로젝트] Prisma 설계하기

Prisma model 구현하기

먼저, 구현될 요구사항을 바탕으로 Prisma의 모델을 작성해보자.

각 테이블의 요구사항을 바탕으로 schema.prisma 파일의 모델을 작성해보자.

 

사용자 ( Users ) 테이블

Name 타입 ( Type ) NULL default
userId ( PK ) INTEGER NOT NULL AUTO_INCREMENT
email STRING NOT NULL  
password STRING NOT NULL  
createdAt DATETIME NOT NULL 현재 시간
updatedAt DATETIME NOT NULL 현재 시간

 

게시글 ( Posts ) 테이블

Name 타입 ( Type ) NULL default
postId ( PK ) INTEGER NOT NULL AUTO_INCREMENT
title STRING NOT NULL  
content TEXT NOT NULL  
ceratedAt DATETIME NOT NULL 현재 시간
updatedAt DATETIME NOT NULL 현재 시간

 

사용자 정보 ( UsersInfos ) 테이블

Name 타입 ( Type ) NULL default
userInfoId ( PK ) INTEGER NOT NULL AUTO_INCREMENT
name STRING NOT NULL  
age INTEGER NULL  
gender STRING NOT NULL  
profileImage STRING NULL  
createdAt DATETIME NOT NULL 현재 시간
updatedAt DATETIME NOT NULL 현재 시간

 

댓글 ( Comments ) 테이블

Name 타입 ( Type ) NULL default
commentId ( PK ) INTEGER NOT NULL AUTO_INCREMENT
content STRING NOT NULL  
createdAt DATETIME NOT NULL 현재 시간
updatedAt DATETIME NOT NULL 현재 시간

 

위 작성한 테이블을 기준으로 Prisma model을 구현해보자.

schema.prisma에 Prisma mode을 입력한다.

 

generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "mysql"
  url      = env("DATABASE_URL")
}

model Users{
	userId Int @id @default(autoincrement()) @map("userId")
	email String @unique @map("email")
	password String @map("password")

	createdAt DateTime @default(now()) @map("createdAt")
	updatedAt DateTime @updatedAt @map("updatedAt")

	@@map("Users")
}

model Posts{
	postId Int @id @default(autoincrement()) @map("postId")
	title String @map("title")
	content String @map("content") @db.Text

	createdAt DateTime @default(now()) @map("createdAt")
	updatedAt DateTime @updatedAt @map("updatedAt")

	@@map("Posts")
}

model UserInfos{
	userInfoId Int @id @default(autoincrement()) @map("userInfoId")
	name String @map("name")
	age Int? @map("age")
	gender String @map("gender")
	profileImage String? @map("profileImage")

	createdAt DateTime @default(now()) @map("createdAt")
	updatedAt DateTime @updatedAt @map("updatedAt")

	@@map("UserInfos")
}

model Comments{
	commentId Int @id @default(autoincrement()) @map("commentId")
	content String @map("content")
	
	createdAt DateTime @default(now()) @map("createdAt")
	updatedAt DateTime @updatedAt @map("updatedAt")

	@@map("Comments")
}

 

Prisma 1:1 관계

요구사항 중 "사용자 ( Users )는 1개의 사용자 정보 ( UserInfo )를 가지고 있다." 에서 사용자와 사용자 정보 모델의 경우 1:1 관계를 가지고 있는 것을 확인할 수 있다. 해당 모델을 비교해 Prisma model은 어떤 방법으로 관계를 설정하는지 확인해보자.

 

// schema.prisma 

model Users {
  userId    Int      @id @default(autoincrement()) @map("userId")
  email     String   @unique @map("email")
  password  String   @map("password")
  createdAt DateTime @default(now()) @map("createdAt")
  updatedAt DateTime @updatedAt @map("updatedAt")

  userInfos UserInfos? // 사용자(Users) 테이블과 사용자 정보(UserInfos) 테이블이 1:1 관계를 맺습니다.

  @@map("Users")
}


model UserInfos {
  userInfoId   Int      @id @default(autoincrement()) @map("userInfoId")
  userId       Int      @unique @map("userId") // 사용자(Users) 테이블을 참조하는 외래키
  name         String   @map("name")
  age          Int?     @map("age")
  gender       String   @map("gender")
  profileImage String?  @map("profileImage")
  createdAt    DateTime @default(now()) @map("createdAt")
  updatedAt    DateTime @updatedAt @map("updatedAt")

  // Users 테이블과 관계를 설정합니다.
  user Users @relation(fields: [userId], references: [userId], onDelete: Cascade)

  @@map("UserInfos")
}

 

사용자 ( Users ) 모델은 사용자 정보 ( UserInfos ) 모델과 1:1관계를 가지고 있다.

여기서, 1:1 관계란 한 사용자가 하나의 사용자 정보만 가질 수 있고, 한 사용자 정보는 한 사용자에게만 속할 수 있다는 것을 의미한다.

 

이런 1:1 관계를 설정할 때는 다음과 같은 내용을 포함해야 한다.

  1. 관계를 설정하려는 모델 ( UserInfos )에서 어떤 모델과 관계를 맺을지 ( Users ) 설정해야 한다.
  2. 관계를 맺게되는 모델 ( Users )에서 어떤 모델이 관계를 맺는지 ( UserInfos ) 설정해야한다.
  3. 관계를 맺게되는 모델 ( Users )에서 타입을 지정할 때, Optional Parameter( ? )를 지정해 줘야한다. ( 사용자는 사용자 정보가 존재하지 않을 수 있기 때문 )

사용자 정보 ( UserInfos ) 모델에서 설정한 부분을 자세히 살펴보자.

 ● Users

  • 일반적인 Int, String과 같은 타입이 아닌, 참조할 다른 모델을 지정한다.
  • 사용자 ( Users ) 모델을 참조하므로 Users로 작성되어있다

 ● fields

    ● 사용자 정보 ( UserInfos ) 모델에서 사용할 외래키 ( Forien Key ) 컬럼을 지정한다.

        ● 여기서는 userId 컬럼으로 외래키를 지정했다.

 ● references

    ●  key : 참조하는 다른 모델의 Column를 지정한다.

        ●  여기서는 사용자 ( Users ) 모델의 userId 컬럼을 참조한다.

● onDelete | onUpdate

    ● 참조하는 모델이 삭제 or 수정될 경우 어떤 행위를 할 지 설정한다.

    ● Cascade 옵션을 선택해 사용자가 삭제될 경우 그에 연결된 사용자 정보도 함께 삭제되도록 설정했다.

 

Prisma 1:N 연관 관계

요구사항 중 "사용자 ( Users )는 여러개의 게시글 ( Posts )을 등록할 수 있다." 에서 사용자와 게시글 모델의 경우 1:N 관계를 가지고 있는 것을 확인할 수 있다. 이번에도 2가지의 모델의 Prisma model에서 어떻게 관계를 설정하는지 확인해보자.

 

먼저 게시글 ( Posts ) model에서 관계를 설정하는 부분을 살펴보자.

// schema.prisma

model Users {
  userId    Int      @id @default(autoincrement()) @map("userId")
  email     String   @unique @map("email")
  password  String   @map("password")
  createdAt DateTime @default(now()) @map("createdAt")
  updatedAt DateTime @updatedAt @map("updatedAt")

  userInfos UserInfos? // 사용자(Users) 테이블과 사용자 정보(UserInfos) 테이블이 1:1 관계를 맺습니다.
  posts     Posts[] // 사용자(Users) 테이블과 게시글(Posts) 테이블이 1:N 관계를 맺습니다.

  @@map("Users")
}

model Posts {
  postId    Int      @id @default(autoincrement()) @map("postId")
  userId    Int      @map("userId") // 사용자(Users) 테이블을 참조하는 외래키
  title     String   @map("title")
  content   String   @map("content") @db.Text
  createdAt DateTime @default(now()) @map("createdAt")
  updatedAt DateTime @updatedAt @map("updatedAt")

  // Users 테이블과 관계를 설정합니다.
  user     Users     @relation(fields: [userId], references: [userId], onDelete: Cascade)

  @@map("Posts")
}

 

사용자 ( Users ) 모델과 게시글 ( Posts ) 모델은 1:N 관계를 가지고 있다.

여기서 1:N 관계랑 한 사용자는 여러개의 게시글을 작성할 수 있다는 것을 의미한다.

 

이런 1:N 관계를 설정할 때는 다음과 같은 내용을 포함해야 한다.

  1. 관계를 설정하려는 모델 ( Posts )에서 어떤 모델과 관계를 맺을지 ( Users ) 설정해야한다.
  2. 관계를 맺게되는 모델 ( Users )에서 어떤 모델이 관계를 맺는지 ( Posts ) 설정해야한다.
  3. 관계를 맺게되는 모델 ( Users )에서 타입을 지정할 때, 배열 연산자 ( [] )를 작성해줘야한다. ( 사용자는 여러개의 게시글을 가질 수 있기 때문 )

현재 게시글 모델의 경우 작성한 사용자가 회원 탈퇴 ( onDelete )하게 될 경우 작성한 모든 게시글이 삭제되도록 구현되어 있다. 이런 설정은 @relation 어노테이션을 사용해 지정한다.

// Users 테이블과 관계를 설정합니다.
user     Users     @relation(fields: [userId], references: [userId], onDelete: Cascade)

 

여기서 User는 게시글 ( Posts )이 참조하는 다른 모델을 지정하고, fields는 게시글 ( Posts ) 모델에서 사용할 외래키 컬럼을 지정한다. references는 참조하는 다른 모델의 컬럼을 지정하고, onDelete는 참조하는 모델이 삭제될 경우 어떤 행위를 할 지 설정한다.

 

onDelete의 경우, Cascade 옵션으로 사용자가 삭제될 경우 연관된 게시글 또한 삭제되도록 설정했다.

 

댓글(Comments) 또한, 게시글 ( Posts )과 마찬가지로 사용자 ( Users ) 모델과 1:N 관계를 가지고 있다.

댓글 ( Comments ) model에서 관계를 설정하는 부분을 살펴보자.

model Users {
  userId    Int      @id @default(autoincrement()) @map("userId")
  email     String   @unique @map("email")
  password  String   @map("password")
  createdAt DateTime @default(now()) @map("createdAt")
  updatedAt DateTime @updatedAt @map("updatedAt")

  userInfos UserInfos? // 사용자(Users) 테이블과 사용자 정보(UserInfos) 테이블이 1:1 관계를 맺습니다.
  posts     Posts[] // 사용자(Users) 테이블과 게시글(Posts) 테이블이 1:N 관계를 맺습니다.
  comments  Comments[] // 사용자(Users) 테이블과 댓글(Comments) 테이블이 1:N 관계를 맺습니다.

  @@map("Users")
}

model Posts {
  postId    Int      @id @default(autoincrement()) @map("postId")
  userId    Int      @map("userId") // 사용자(Users) 테이블을 참조하는 외래키
  title     String   @map("title")
  content   String   @map("content") @db.Text
  createdAt DateTime @default(now()) @map("createdAt")
  updatedAt DateTime @updatedAt @map("updatedAt")

  // Users 테이블과 관계를 설정합니다.
  user     Users      @relation(fields: [userId], references: [userId], onDelete: Cascade)
  comments Comments[] // 게시글(Posts) 테이블과 댓글(Comments) 테이블이 1:N 관계를 맺습니다.

  @@map("Posts")
}

model Comments {
  commentId Int      @id @default(autoincrement()) @map("commentId")
  postId    Int      @map("postId") // 게시글(Posts) 테이블을 참조하는 외래키
  userId    Int      @map("userId") // 사용자(Users) 테이블을 참조하는 외래키
  content   String   @map("content")
  createdAt DateTime @default(now()) @map("createdAt")
  updatedAt DateTime @updatedAt @map("updatedAt")

  // Posts 테이블과 관계를 설정합니다.
  post Posts @relation(fields: [postId], references: [postId], onDelete: Cascade)
  // Users 테이블과 관계를 설정합니다.
  user Users @relation(fields: [userId], references: [userId], onDelete: Cascade)

  @@map("Comments")
}

 

게시판 프로젝트 최종 Prisma model

// schema.prisma

generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "mysql"
  url      = env("DATABASE_URL")
}

model Users {
  userId    Int      @id @default(autoincrement()) @map("userId")
  email     String   @unique @map("email")
  password  String   @map("password")
  createdAt DateTime @default(now()) @map("createdAt")
  updatedAt DateTime @updatedAt @map("updatedAt")

  userInfos UserInfos? // 사용자(Users) 테이블과 사용자 정보(UserInfos) 테이블이 1:1 관계를 맺습니다.
  posts     Posts[] // 사용자(Users) 테이블과 게시글(Posts) 테이블이 1:N 관계를 맺습니다.
  comments  Comments[] // 사용자(Users) 테이블과 댓글(Comments) 테이블이 1:N 관계를 맺습니다.

  @@map("Users")
}

model Posts {
  postId    Int      @id @default(autoincrement()) @map("postId")
  userId    Int      @map("userId") // 사용자(Users) 테이블을 참조하는 외래키
  title     String   @map("title")
  content   String   @map("content") @db.Text
  createdAt DateTime @default(now()) @map("createdAt")
  updatedAt DateTime @updatedAt @map("updatedAt")

  // Users 테이블과 관계를 설정합니다.
  user     Users      @relation(fields: [userId], references: [userId], onDelete: Cascade)
  comments Comments[] // 게시글(Posts) 테이블과 댓글(Comments) 테이블이 1:N 관계를 맺습니다.

  @@map("Posts")
}

model UserInfos {
  userInfoId   Int      @id @default(autoincrement()) @map("userInfoId")
  userId       Int      @unique @map("userId") // 사용자(Users) 테이블을 참조하는 외래키
  name         String   @map("name")
  age          Int?     @map("age")
  gender       String   @map("gender")
  profileImage String?  @map("profileImage")
  createdAt    DateTime @default(now()) @map("createdAt")
  updatedAt    DateTime @updatedAt @map("updatedAt")

  // Users 테이블과 관계를 설정합니다.
  user Users @relation(fields: [userId], references: [userId], onDelete: Cascade)

  @@map("UserInfos")
}

model Comments {
  commentId Int      @id @default(autoincrement()) @map("commentId")
  postId    Int      @map("postId") // 게시글(Posts) 테이블을 참조하는 외래키
  userId    Int      @map("userId") // 사용자(Users) 테이블을 참조하는 외래키
  content   String   @map("content")
  createdAt DateTime @default(now()) @map("createdAt")
  updatedAt DateTime @updatedAt @map("updatedAt")

  // Posts 테이블과 관계를 설정합니다.
  post Posts @relation(fields: [postId], references: [postId], onDelete: Cascade)
  // Users 테이블과 관계를 설정합니다.
  user Users @relation(fields: [userId], references: [userId], onDelete: Cascade)

  @@map("Comments")
}

 

Prisma DB, Table 생성

.env 설정

# .env

DATABASE_URL="mysql://root:aaaa4321@express-database.qeradf.ap-northeast-2.rds.amazonaws.com:3306/community_hub"

 

자신이 대여중인 아마존 RDS EndPoint를 참조해서 경로를 설정한다.

 

아래 명령어를 입력해 db와 앞서 설계한 테이블을 생성한다.

# 해당 프로젝트에 schema.prisma에 정의된 테이블을 MySQL에 생성합니다.
npx prisma db push

 

생성 모습

 

+ Recent posts