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

 

+ Recent posts