티스토리 뷰

728x90
반응형

이전 글에서 MVC 아키텍처를 적용하고, Express 프레임워크를 사용한 Node.js 프로젝트의

 

기본 패키지 구성과 그 안에 속한 파일을 알아봤다.

 

오늘부터는 해당 패키지 안에 존재하는 모듈에 대해서 한 줄씩 파보려고 한다.

 

그러다 보면 내 JS 이해도가 따라오겠지.

 

Terminology

 

시작하기 전에 먼저 단어를 정리하고 넘어가자.

 

물론 아래의 설명은 초심자가 어깨너머로 정리한 것이라 틀릴 확률이 높다.

 

Node.js에서 각 .ts 파일들은 '모듈'이라고 불린다.

 

이 모듈은 자바의 클래스와 비슷하게 느껴지지만 서로 다른 목적과 개념을 가지고 있으며,

 

(예를 들자면 모듈은 코드의 모듈화가, 클래스는 객체 지향 프로그래밍이 주된 목적이다)

 

공통점으로는 모두 코드의 재사용성과 가독성을 향상시키고, 구조를 잡는데 도움이 된다는 점이 있다.

 

조금만 더 정리하면 Node.js의 모듈은 코드를 재사용 가능한 조각으로 나눈 것이며,

 

그 안에 변수, 함수, 클래스 등을 정의하고, import 하고, export 할 수 있다.

 

이어서 모듈을 담고 있는 폴더는 그냥 그대로 '폴더', 혹은 '디렉터리'로 불린다.

 

단어 정리가 대충 끝났으니, 원래 목적으로 들어가자.

 

이 글의 목표는 /src/config/db.ts 모듈과 /src/model/User.ts, /src/model/Archive.ts 모듈이다.

 

/src/config/db.ts

 

먼저 타입스크립트로 이루어진 코드를 보자.

import mongoose from 'mongoose';
import dotenv from 'dotenv';

dotenv.config();

const connectDB = async (): Promise<void> => {
  try {
    await mongoose.connect(process.env.MONGO_URI!);

    console.log(`MongoDB Connected: ${process.env.MONGO_URI}`);
  } catch (error) {
    console.error(`Error: ${(error as Error).message}`);
    process.exit(1);
  }
};

export default connectDB;

계속해서 한 줄씩 읽어보자.

 

  • import mongoose from 'mongoose';
    쓰인 대로 mongoose 라이브러리를 가져온다. 여기서 mongoose는 MongoDB와 상호작용을 위한
    node.js 기반의 ODM(Object-Document Mapping) 라이브러리이다.

    • ODM(Object-Document Mapping)?
      이름대로 읽으면 객체-문서 매핑이다. NoSQL 중에서도 MongoDB와 같은 문서 지향 DB에서 사용된다.
      스키마 정의 및 유효성 검사, CRUD 연산, 객체와 도큐먼트 사이의 데이터를 자동으로 변환하는 기능을 가지고 있다.
      이는 당연하게도 개발자가 DB의 세부사항보다는 객체 지향적으로 코드에 집중할 수 있게 해 준다.
      특히 Mongoose는 스키마 기반의 모델링, 유효성 검사, 미들웨어, 쿼리 구축 등을 지원하는 Node.js 라이브러리이다.
  • import dotenv from 'dotenv';
    dotenv 라이브러리를 가져온다. 해당 라이브러리는 .env 파일의 환경변수를 사용할 수 있게 해 준다.
  • dotenv.config();
    프로젝트 루트에 존재하는 .env 파일의 환경변수를 가져온다.
  • const connectDB = async (): Promise<void> => { ... };
    'connectDB'라는 비동기(async) 함수를 정의한다. 해당 함수는 MongoDB에 연결하는 역할을 한다.

    • 함수를 정의하는데 특이하게 const로 시작하는 것이 신기해서 좀 알아보니 나름 이유가 있었다.
      가장 먼저 const 키워드를 사용해 함수를 선언하면, 해당 함수는 재할당이 불가능해 코드의 안전성을 높인다.
      다음으로 const는 블록 스코프를 가지기 때문에 함수 내부의 변수와 상수는 함수가 선언된 블록 내에서만 접근이 가능해진다.
      이는 코드의 가독성을 높이고, 변수의 유효범위를 제한해 예상치 못한 예외를 막을 수 있다.
      또한 함수를 다른 함수의 매개변수로 전달하거나 반환 타입으로 사용할 때 코드가 간결해지고 가독성이 높아진다.
      마지막으로 화살표 함수를 사용해 'function' 키워드 없이 함수를 간결하게 정의할 수 있으며,
      이렇게 구현한 경우 'this'가 자신을 가리키지 않고 상위 범위의 'this'를 가리키므로 콜백함수에서 참조 시 유용하다.
      따라서, JS와 TS에서 추천되는 함수 선언 방법이라고 한다.
  • await mongoose.connect(process.env.MONGO_URI!);
    mongoose.connect() 메서드를 사용해 MongoDB에 연결한다. URI는 .env 파일에서 읽어오며,
    URI 뒤의 느낌표(!)는 타입스크립트의 문법으로, MONGO_URI가 반드시 존재하며, null이나 undefined가 아니라는 것을
    명시적으로 지정할 때 쓰인다. 이렇게 하면 타입스크립트는 해당 라인에서 null, undefined 관련 에러를 발생시키지 않는다.
  • console.log(`MongoDB Connected: ${process.env.MONGO_URI`};
    성공적으로 연결된 경우, 연결된 URI 정보를 콘솔에 출력한다.
  • console.error(`Error: ${(error as Error).message`};
    에러가 발생하면 해당 에러 메시지를 콘솔에 출력한다.
  • process.exit(1);
    에러가 발생한 경우, 실패 상태 코드 1을 반환하며 프로세스를 종료한다.
  • export default connectDB;
    connectDB 함수 자체를 default 모듈로 내보낸다. 이렇게 하면 다른 모듈에서 함수를 불러서 사용할 수 있다.

 

/src/model/User.ts

 

역시 먼저 코드를 보자.

import mongoose from 'mongoose';
import bcrypt from 'bcryptjs';

export interface UserDocument extends mongoose.Document {
  email: string;
  password: string;
  matchPassword: (enteredPassword: string) => Promise<boolean>;
}

const userSchema = new mongoose.Schema<UserDocument>({
  email: { type: String, required: true, unique: true },
  password: { type: String, required: true },
});

userSchema.pre('save', async function (next) {
  if (!this.isModified('password')) {
    next();
  }

  const salt = await bcrypt.genSalt(10);
  this.password = await bcrypt.hash(this.password, salt);
});

userSchema.methods.matchPassword = async function (enteredPassword: string) {
  return await bcrypt.compare(enteredPassword, this.password);
};

const User = mongoose.model<UserDocument>('User', userSchema);

export default User;

위에서 다뤘던 모듈보다 길이가 길다. 그래도 한 조각씩 읽어보자.

import mongoose from 'mongoose';
import bcrypt from 'bcryptjs';

mongoose는 위에서 다뤘으니 넘어가고, bcryptjs는 비밀번호 해싱에 사용되는 라이브러리이다.

export interface UserDocument extends mongoose.Document {
  email: string;
  password: string;
  matchPassword: (enteredPassword: string) => Promise<boolean>;
}

mongoose.Document를 상속한 UserDocument 인터페이스를 정의한다.

 

  • export: 해당 인터페이스에서 정의된 요소(변수, 함수, 클래스, 타입 등)를 다른 모듈에서 import 해서 사용할 수 있도록 한다.
  • interface
    자바와 스프링부트에서의 인터페이스와 비슷하게 추상화를 위한 도구로 사용된다.
    특정 클래스가 따라야 할 규칙과 메서드 시그니처, 속성, 속성의 타입이 포함된다.
    다만 자바와 다르게 TS의 인터페이스는 컴파일 타임에만 존재하며, 객체의 행동보다는 구조를 정의하는데 중점을 둔다고 한다.
  • mongoose.Document
    몽구스에서 제공되는 기본 클래스이다. 이는 MongoDB의 도큐먼트를 JS 객체로 나타내는 데 사용되며,
    도큐먼트의 _id, __v와 같은 기본 필드와 save(), remove(), validate()등의 메서드를 제공한다.
    여기서 UserDocument 메서드는 이를 상속받아 email, password 필드와 matchPassword() 메서드를 추가로 정의한다.

이와 같은 과정을 거쳐 UserDocument의 형태를 명시적으로 정의하게 된다.

const userSchema = new mongoose.Schema<UserDocument>({
  email: { type: String, required: true, unique: true },
  password: { type: String, required: true },
});

여기서는 실제로 MongoDB에서 사용할 userSchema를 정의한다.

 

  • mongoose.Schema
    몽구스의 스키마 생성자이다. 이는 몽고디비의 컬렉션에 저장될 도큐먼트의 구조를 정의한다.
    여기서 구조란 필드의 종류와 타입, 필수적인지 등을 가리킨다.
  • <UserDocument>
    TypeScript의 제네릭 문법을 사용한 것으로, mongoose.Schema를 통해 생성되는 스키마가 
    UserDocument 인터페이스를 준수하도록 지정한다. 이는 타입 안정성을 보장하며,
    UserDocument 인터페이스에 정의된 필드 이외의 필드를 추가하려고 하면 에러를 발생시키게 된다.
  • mongoose.Schema<UserDocument>따라서 위와 같이 정의된 userSchema는 UserDocument 인터페이스를 준수하는 데이터만
    컬렉션에 저장되도록 보장하는 '스키마'를 생성한다.

여기서는 'email'과 'password'만 정의된 상태이며 두 필드 모두 필수에 email은 고유해야 한다는 규칙이 정해져 있다.

userSchema.pre('save', async function (next) {
  if (!this.isModified('password')) {
    next();
  }

  const salt = await bcrypt.genSalt(10);
  this.password = await bcrypt.hash(this.password, salt);
});

몽구스의 미들웨어를 정의하는 부분이다. userSchema.pre('save', callback)은

 

'save' 작업이 수행되기 전에 실행될 함수를 정의한다.

 

isModified는 몽구스의 Document 객체의 메서드로 특정 필드가 수정되었는지 확인한다.

 

여기선 비밀번호가 변경되지 않았다면 next()를 통해 추가 동작 없이 다음 미들웨어 함수로 제어를 넘기고

 

만약 비밀번호가 변경되었다면 bcrypt를 사용해 새 비밀번호를 해싱하는 로직이 실행된다.

 

즉, 비밀번호 필드가 수정되는 상황에서만 'save' 실행 전 해싱 작업이 수행된다는 뜻이다.

userSchema.methods.matchPassword = async function (enteredPassword: string) {
  return await bcrypt.compare(enteredPassword, this.password);
};

여기서는 몽구스의 스키마에 속한 method 키워드를 사용해서 스키마에 matchPassword를 추가하고 있다.

 

해당 메서드는 이름처럼 입력된 비밀번호와 해싱된(저장된) 비밀번호를 비교해 같으면 true를, 그렇지 않으면 false를 반환한다.

const User = mongoose.model<UserDocument>('User', userSchema);

export default User;

끝으로, 몽구스의 model 함수와 userSchema를 이용해 'User' DB 모델을 생성하고 이를 export 한다.

 

조금 더 상세히 보자면 model 함수는 첫 번째 인자로 모델의 이름을, 두 번째 인자로 스키마를 입력받으며

 

여기서 UserDocument는 앞서 말한 타입스크립트의 제네릭으로, 모델의 도큐먼트 타입을 정의하고 있다.

 

즉, 여기서 생성된 'User'라는 이름의 모델은 'userSchema'를 따르고, 각 도큐먼트는 'UserDocument'에 정의된

 

필드와 메서드를 가져야 한다는 것을 명시하고 있는 것이다.

 

또한 mongoose.model의 반환값은 그 이름과 스키마에 따른 모델의 '클래스'로,

 

디비에 도큐먼트에 대한 CRUD 작업을 수행하는 메서드를 기본으로 가지고 있게 된다.

 

끝으로, default로 export 했기 때문에 해당 모델도 다른 모듈에서 불러서 사용할 수 있게 된다.

 

/src/model/Archive.ts

 

import mongoose from "mongoose";

export interface IArchive extends mongoose.Document {
  email: string;
  type: string;
  productChoice: string;
  purpose: string;
  customerGender?: string;
  customerAge?: string;
  customerDetails?: string;
  USP?: number;
  emotion: string;
  specialFeature: string;
  imageConcept: string;
  keyCopy: string;
  subCopy: string;
  imageFiles: string[];
}

const ArchiveSchema = new mongoose.Schema({
  email: { type: String, required: true, unique: true },
  type: { type: String, required: true, unique: true },
  productChoice: { type: String, required: true },
  purpose: { type: String, required: true },
  customerGender: { type: String, required: false },
  customerAge: { type: String, required: false },
  customerDetails: { type: String, required: false },
  USP: { type: Number, required: false },
  emotion: { type: String, required: true },
  specialFeature: { type: String, required: true },
  imageConcept: { type: String, required: true },
  keyCopy: { type: String, required: true },
  subCopy: { type: String, required: true },
  imageFiles: { type: [String], required: true },
});

ArchiveSchema.pre("save", async function (next) {
});

const Archive = mongoose.model<IArchive>("Archive", ArchiveSchema);

export default Archive;

길이는 길지만 내용 자체는 짧은 모듈이다.

export interface IArchive extends mongoose.Document {
  email: string;
  type: string;
  productChoice: string;
  purpose: string;
  ...
}

앞서 봤던 mongoose.Document를 상속해 IArchive라는 인터페이스를 선언한다.

 

인터페이스의 이름(IArchive)과 정의된 필드의 종류를 볼 때 애플리케이션 전체에서

 

사용자 정보나 행동을 기록하는 기능을 가진 영역에서 사용될 것으로 보인다.

const ArchiveSchema = new mongoose.Schema({
  email: { type: String, required: true, unique: true },
  type: { type: String, required: true, unique: true },
  productChoice: { type: String, required: true },
  purpose: { type: String, required: true },
  ...
});

계속해서 위에서 선언한 데이터의 스키마를 정의한다.

 

각 필드의 타입, 필수 여부 등이 명시적으로 정해진다.

ArchiveSchema.pre('save', async function (next) {
});

역시 'save' 이전에 수행할 ArchiveSchema의 미들웨어를 정의하는 부분이다.

 

현재는 비어있다.

const Archive = mongoose.model<IArchive>("Archive", ArchiveSchema);

export default Archive;

끝으로 몽구스의 model 함수를 사용해 'Archive'라는 이름의 DB 모델을 만들고,

 

export default로 내보내 다른 모듈에서 불러 사용할 수 있도록 한다.

 

구체적으로 다시 설명하자면 이 모델은 'Archive'라는 이름의 컬렉션에 대응하며,

 

그 안의 모든 도큐먼트는 IArchive 인터페이스와 스키마에 정의된 구조를 따라야 한다.

반응형
댓글
공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2024/06   »
1
2 3 4 5 6 7 8
9 10 11 12 13 14 15
16 17 18 19 20 21 22
23 24 25 26 27 28 29
30
글 보관함