본문으로 건너뛰기

© 2026 Molayo

Dev.to헤드라인2026. 06. 25. 18:15

Node.js 및 MongoDB를 활용한 고급 API 인증 전략 (Part 1)

요약

Node.js, Express, MongoDB를 사용하여 프로덕션 수준의 보안 인증 시스템을 구축하는 3부작 튜토리얼의 첫 번째 파트입니다. bcrypt를 활용한 비밀번호 해싱과 사용자 등록 및 로그인 기초 구현을 다룹니다.

핵심 포인트

  • bcrypt를 이용한 안전한 비밀번호 해싱 구현
  • Node.js와 MongoDB 기반의 사용자 등록 및 로그인 로직 구축
  • MFA, JWT, 세션 관리 등 고급 인증 전략으로 이어지는 기초 단계
  • 사이버 공격에 대비한 보안 인증 계층의 중요성 강조

이 기사는 Moses Anumadu에 의해 작성되었습니다.

Node.js 및 MongoDB를 활용한 비밀번호 인증 구축

대부분의 데이터베이스 기반 애플리케이션은 사용자의 인증 (Authentication)을 필요로 합니다. 이는 놀라운 일이 아닙니다. 진짜 놀라운 점은 매일 발생하는 사이버 공격의 횟수와 그 복잡성에 대해 들을 때일 것입니다. Akamai Technologies에 따르면, 하루에 3억 건 이상의 자격 증명 스터핑 (Credential-stuffing) 공격과 5억 건 이상의 공격이 발생하고 있습니다. 이러한 공격 규모를 고려할 때, 애플리케이션이 전통적인 이메일 및 비밀번호 인증에 더 많은 인증 계층 (MFA, 다요소 인증)을 추가하는 것은 합리적입니다.

이 3부작 튜토리얼은 Node.js, Express, MongoDB Atlas를 사용하여 기본적인 회원가입 및 로그인을 넘어선 프로덕션 스타일의 인증 시스템을 구축하는 과정을 안내합니다.

이 3부작 시리즈의 첫 번째 파트 (Part 1)에서는 시리즈 전체에 걸쳐 구축해 나갈 인증의 기초를 구현할 것입니다. 다음 사항들을 구현합니다:

  • bcrypt를 이용한 안전한 비밀번호 해싱 (Hashing).
  • 이메일 및 비밀번호를 이용한 사용자 등록 (Registration).
  • 이메일 및 비밀번호를 이용한 사용자 로그인 (Login).

Part 2에서는 기존 코드베이스를 바탕으로 구축을 계속합니다. 다음 사항들을 구현하여 코드에 안전한 세션 관리 (Session management)를 추가할 것입니다:

  • 로그인 검증 (Verification)
  • TOTP 기반 MFA 설정 및 검증
  • TOTP 설정을 위한 QR 코드 온보딩 (Onboarding)
  • 부분 인증 (Half-authenticated) 로그인 흐름

그리고 Part 3에서는 다음 사항들을 구현하여 실제 애플리케이션에 배포하거나 사용할 수 있는 인증 시스템을 완성할 것입니다:

  • JWT 액세스 토큰 (Access tokens)
  • 리프레시 토큰 (Refresh tokens)
  • MongoDB 기반 세션 (Sessions)
  • 리프레시 토큰 로테이션 (Refresh token rotation)
  • 보호된 경로 (Protected routes)
  • 안전한 로그아웃 및 세션 취소 (Revocation)

사전 요구 사항 (Pre-requisites)

이 튜토리얼을 따라오려면, 위에 언급된 스택에 대한 개발 환경이 설정되어 있어야 하며 다음 사항을 갖추어야 합니다:

  • Node.js 및 Express에 대한 이해
  • MongoDB에 대한 이해
  • MongoDB Atlas 계정
  • API 엔드포인트 테스트를 위한 Postman

자, 이제 시작해 봅시다.

프로젝트 설정 (Project Setup)

시작하기 위해, 새로운 Node.js 프로젝트를 생성하고 필요한 모든 의존성(dependencies)을 설치해야 합니다. 원하는 디렉토리에서 아래 명령어를 사용하여 프로젝트를 생성하세요:

mkdir devrel-node-api-authentication
cd devrel-node-api-authentication
npm init -y

우리가 구축하려는 것과 같은 고급 인증 시스템은 Node.js의 여러 의존성에 의존합니다. 이러한 역할을 처리하기 위해 몇 가지 패키지를 설치할 것입니다. 터미널에서 프로젝트 디렉토리로 이동한 다음 아래 명령어를 사용하여 의존성을 설치하세요.

npm install express mongoose dotenv bcryptjs jsonwebtoken otplib qrcode && npm install --save-dev nodemon

방금 설치한 모든 패키지를 잠시 살펴보겠습니다. Express는 API 구축을 위한 웹 서버와 라우팅(routing) 시스템을 제공하며, Mongoose는 모델(models)과 스키마(schemas)를 통해 MongoDB와 상호작용할 수 있게 해줍니다. dotenv는 데이터베이스 자격 증명 및 비밀 키와 같은 환경 변수(environment variables)를 로드하는 데 도움을 줍니다. bcryptjs는 비밀번호를 데이터베이스에 저장하기 전에 안전하게 해싱(hashing)하며, jsonwebtoken은 인증을 위한 JWT 토큰을 생성하고 검증할 수 있게 합니다. MFA(다요소 인증) 기능을 위해 otplib는 시간 기반 일회용 비밀번호(TOTP)를 생성하고 검증하며, qrcode는 MFA 설정 정보를 인증 앱에서 스캔 가능한 QR 코드로 변환합니다. 마지막으로 nodemon은 코드 변경이 감지될 때마다 서버를 자동으로 재시작하여 개발 경험을 향상시킵니다.

설치가 완료되면, package.json 파일의 scripts 섹션을 아래 코드로 업데이트해야 합니다:

"scripts": {
  "dev": "nodemon src/server.js"
}

이렇게 하면 npm run dev를 사용하여 프로젝트를 실행할 수 있습니다.

프로젝트 구조 생성하기

진행 상황이 좋습니다. 이제 프로젝트 구조를 생성하며 계속 진행해 보겠습니다. 프로젝트 내에 다음 파일과 폴더들을 생성하세요. 프로젝트 구조는 아래 샘플과 같아야 합니다.

src/
├── config/
├── controllers/
...

MongoDB Atlas 설정하기

다음으로, MongoDB Atlas 클러스터와 상호작용할 수 있도록 애플리케이션을 설정하겠습니다. Atlas 연결 문자열 (connection string)을 준비해 주세요. Atlas 계정이 없다면, 링크를 따라 가입하여 Atlas 클러스터를 생성한 후 연결 문자열을 확보하시기 바랍니다. 준비가 되었다면, 프로젝트 루트 디렉토리에 .env 파일을 생성하고 아래 코드로 내용을 업데이트하세요:

MONGO_URI_BASE=mongodb+srv://USERNAME:PASSWORD@cluster0.xxxxx.mongodb.net

JWT_ACCESS_SECRET=supersecretaccesskey
JWT_REFRESH_SECRET=supersecretrefreshkey

ACCESS_TOKEN_EXPIRES_IN=15m
REFRESH_TOKEN_EXPIRES_DAYS=7
MFA_TOKEN_EXPIRES_IN=5m

PORT=5000

이제 Atlas 연결 문자열이 준비되었으므로, Mongoose를 사용하여 애플리케이션을 MongoDB에 연결해 보겠습니다. src/config/db.js 파일을 생성하고 아래 코드로 파일을 업데이트하세요.

const mongoose = require("mongoose");

const databaseName = "devrel-node-auth-db";
const appName = "devrel-tutorial-nodejs-authentication-geeksforgeeks";

const connectDB = async () => {
try {
  const mongoUri = `${process.env.MONGO_URI_BASE}/${databaseName}?retryWrites=true&w=majority&appName=${appName}`;

  await mongoose.connect(mongoUri);

  console.log("MongoDB connected");
  console.log(`Database: ${databaseName}`);
  console.log(`App Name: ${appName}`);
} catch (error) {
  console.error("MongoDB connection failed:");
  console.error(error.message);

  process.exit(1);
}
};

module.exports = connectDB;

데이터베이스 이름과 appName이 환경 변수 파일이 아닌 코드베이스 내에 직접 정의되어 있다는 점에 주목하세요. 이는 인프라 수준의 설정을 여러 환경에서 일관되게 유지해 줍니다.

...

const express = require("express");
const authRoutes = require("./routes/authRoutes");
const app = express();

app.use(express.json());

app.use("/api/auth", authRoutes);
module.exports = app;

src/app.js에서 authRoutes.js 파일을 임포트(import)하여 사용했습니다. 다음 단계로 넘어가기 전에 이 파일을 먼저 생성해 보겠습니다.

src/routes/ 디렉토리에 authRoutes.js 파일을 생성하고 아래 코드로 업데이트하세요:

const express = require("express");

const router = express.Router();

router.get("/health", (req, res) => {
 res.status(200).json({ message: "Auth routes are working" });
});

module.exports = router;

애플리케이션 설정이 올바른지 확인하기 위해 간단한 /health 라우트(route)를 생성했습니다.

...

const dotenv = require("dotenv");
const app = require("./app");
const connectDB = require("./config/db");

dotenv.config();

const PORT = process.env.PORT || 5000;

const startServer = async () => {
 await connectDB();
 app.listen(PORT, () => {
   console.log(`Server running on port ${PORT}`);
 });
};

startServer();

이제 지금까지의 설정을 테스트할 수 있습니다. 프로젝트 디렉토리에서 아래 명령어를 사용하여 개발 서버를 시작하세요:

npm run dev

모든 설정이 올바르게 되었다면, 터미널에 다음과 같이 표시될 것입니다:

MongoDB connected
Server running on port 5000

User 모델 생성

...

const mongoose = require("mongoose");

const userSchema = new mongoose.Schema(
 {
   email: {
     type: String,
     required: true,
     unique: true,
     lowercase: true,
     trim: true,
   },

   passwordHash: {
     type: String,
     required: true,
   },

   mfaEnabled: {
     type: Boolean,
     default: false,
   },

   mfaSecret: {
     type: String,
     default: null,
   },
 },
 {
   timestamps: true,
 }
);

module.exports = mongoose.model("User", userSchema);

위 코드를 통해 User 모델을 생성했습니다. User 스키마(schema)와 User 모델을 생성하기 위해 Mongoose가 사용되었습니다. 한 가지 더 주목할 점은, 스키마에 따라 password 대신 passwordHash를 저장한다는 것입니다. passwordHash를 사용하면 비밀번호가 평문(plain text)으로 저장되지 않고 bcrypt를 사용하여 해싱(hashing)되어 저장됨을 보장합니다.

...

const bcrypt = require("bcryptjs");
const User = require("../models/User");

//이제 회원가입 컨트롤러(registration controller)를 추가합니다:

const register = async (req, res) => {
  try {
    const { email, password } = req.body;

    // 기본 유효성 검사 (Basic validation)
    if (!email || !password) {
      return res.status(400).json({
        success: false,
        message: "이메일과 비밀번호는 필수 항목입니다",
      });
    }

    // 비밀번호 길이 유효성 검사 (Password length validation)
    if (password.length < 6) {
      return res.status(400).json({
        success: false,
        message: "비밀번호는 최소 6자 이상이어야 합니다",
      });
    }

    // 중복 계정 방지 (Prevent duplicate accounts)
    const existingUser = await User.findOne({ email });

    if (existingUser) {
      return res.status(409).json({
        success: false,
        message: "이미 존재하는 사용자입니다",
      });
    }

    // 비밀번호 해싱 (Hash password)
    const passwordHash = await bcrypt.hash(password, 12);

    // 사용자 생성 (Create user)
    const user = await User.create({
      email,
      passwordHash,
    });

    return res.status(201).json({
      success: true,
      message: "사용자가 성공적으로 등록되었습니다",
      user: {
        id: user._id,
        email: user.email,
      },
    });
  } catch (error) {
    console.error(error);

    return res.status(500).json({
      success: false,
      message: "서버 오류",
    });
  }
};
module.exports = { register };

위의 코드에서 볼 수 있듯이, 이메일과 비밀번호 값이 비어 있는지, 비밀번호 길이는 적절한지 유효성 검사를 수행했으며, 이메일이 이미 존재하는지 확인하여 중복 계정을 방지했습니다. 다음에 주목해야 할 중요한 점은 비밀번호가 bcrypt를 사용하여 해싱(hashing)되었다는 것입니다.
const passwordHash = await bcrypt.hash(password, 12);.
...

const express = require("express");

const {
  register,
} = require("../controllers/authController");

const router = express.Router();

router.post("/register", register);

module.exports = router;

회원가입 테스트 (Testing Registration)

...

npm run dev

다른 터미널 창에서 아래 명령어를 사용하여 Ngrok을 시작하세요:


Ngrok http 5000

아래 이미지와 유사한 출력을 예상할 수 있습니다:

...

{
  "email": "test_user@example.com",
  "password": "password123"
}

귀하의 출력은 아래 이미지와 유사해야 합니다:

...

const login = async (req, res) => {
  try {
    const { email, password } = req.body;

    // 기본 유효성 검사 (Basic validation)
    if (!email || !password) {
      return res.status(400).json({
        success: false,
        message: "Email and password are required",
      });
    }

    // 사용자 찾기 (Find user)
    const user = await User.findOne({ email });

    // 이메일 열거 공격 방지 (Prevent email enumeration attacks)
    if (!user) {
      return res.status(401).json({
        success: false,
        message: "Invalid credentials",
      });
    }

    // 저장된 해시와 비밀번호 비교 (Compare password with stored hash)
    const isPasswordValid = await bcrypt.compare(
      password,
      user.passwordHash
    );

    if (!isPasswordValid) {
      return res.status(401).json({
        success: false,
        message: "Invalid credentials",
      });
    }

    return res.status(200).json({
      success: true,
      message: "Login successful",
      user: {
        id: user._id,
        email: user.email,
        mfaEnabled: user.mfaEnabled,
      },
    });
  } catch (error) {
    console.error(error);

    return res.status(500).json({
      success: false,
      message: "Server error",
    });
  }
};

또한, 다음과 같이 export를 업데이트하고 방금 생성한 register 함수를 추가하세요:

module.exports = {
  register,
  login,
};

코드에서 우리는 여러 가지 유효성 검사 (validations)를 수행했습니다. 첫 번째는 빈 문자열을 확인하는 것이었고, 그다음에는 이 사용자가 데이터베이스에 존재하는지 확인했습니다. 잘못된 이메일과 잘못된 비밀번호 모두 동일한 응답인 "Invalid credentials"를 반환한다는 점을 눈치채셨나요? 이는 공격자가 시스템에 어떤 이메일 주소가 존재하는지 알아내는 것을 방지합니다. 그 후, 입력된 비밀번호를 데이터베이스의 비밀번호와 비교했습니다. 또한 비밀번호는 복호화된 것이 아니라, 대신 데이터베이스에 이미 저장된 암호화된 버전과 비교되었다는 점에 유의하세요.

...


const express = require("express");

const {
  register,
  login,
} = require("../controllers/authController");

AI 자동 생성 콘텐츠

본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.

원문 바로가기
0

댓글

0