티스토리 뷰

728x90
반응형

목차

     

     

    지난 글에선 기본 패키지 구조를 잡고, 게시판 구성을 위한 환경 변수 파일(.env)과

     

    모델, 컨트롤러, 라우터, 그리고 실행 파일인 서버 파일까지 구성해 보았다.

     

    이번 글에선 사용자(User)를 위한 모듈과 JWT토큰 발급, 그리고 이를 이용한 회원가입과 로그인 로직을 구성해 보겠다.

     

    들어가기 전에, 이번 글에서 완성하게 될 디렉토리와 모듈 구성은 아래와 같다.

     

    기존 모듈의 코드 구성에도 변화가 조금 있어서, 변한 부분을 가능한 빼먹지 않고 적어 보겠다.

     

    .env

     

    JWT를 위한 시크릿을 추가해 준다.

    PORT=3000
    DB_URL=mongodb://localhost:27017/testdb
    JWT_SECRET=ThisIsTheJWTSecretForNodeJSPractice

     

    /src/middleware

     

    지난 글에선 Feed.ts 모듈 안에 시퀀스 자동증가 미들웨어를 포함시켰었다.

     

    지금부턴 해당 미들웨어를 별개의 모듈로 분리하고, Feed와 User 모듈뿐 아니라

     

    앞으로 구현하게 될 다른 모델에서도 사용할 수 있도록 할 것이다.

     

    지난 글에 써진 코드는 생략하고, 바로 미들웨어 구현으로 들어가 보자.

     

    /autoIncrement.ts

     

    import { Document, Model } from 'mongoose';
    
    interface IAutoIncrement {
      [key: string]: number;
    }
    
    export const autoIncrement = (field: string) => {
      return async function (this: Document & IAutoIncrement, next: () => void) {
        if (this.isNew) {
          const Model = this.constructor as Model<Document & IAutoIncrement>;
          const lastDoc: (Document & IAutoIncrement) | null = await Model.findOne({}, {}, { sort: { [field]: -1 } });
          if (lastDoc && lastDoc[field]) {
            this[field] = lastDoc[field] + 1;
          } else {
            this[field] = 1;
          }
        }
        next();
      };
    };

    기존에 Feed 모듈에 속했던 자동 증가 미들웨어를 별개의 모듈로 분리했다.

     

    먼저, 이 코드는 몽구스 도큐먼트에 대한 자동 증가를 구현한 것이다.

    import { Document, Model } from 'mongoose';

    따라서 먼저 몽구스에서 'Document'와 'Model'을 명시적으로 가져온다.

     

    다시 설명하자면, Document란 몽구스 모델의 객체, 그러니까 각각의 도큐먼트를 가리키며

     

    Model은 몽구스 모델 클래스를 가리킨다.

    interface IAutoIncrement {
      [key: string]: number;
    }

    이어서 인터페이스를 정의한다. 항상 인터페이스를 먼저 정의하는 이유는 

     

    TS에선 모든 변수, 속성, 매개변수 등의 타입을 명시적으로 정의해야 하기 때문이며,

     

    이를 가장 간단하게 정의하는 방법이 인터페이스이기 때문이다.

     

    여기서 정의된 'IAutoIncrement' 인터페이스는 몽구스 도큐먼트에 추가적인 속성을 부여한다.

     

    이와 같이 정의하면 this 키워드를 통해 현재 도큐먼트에 접근할 때,

     

    해당 도큐먼트가 'IAutoIncrement' 속성을 가지고 있다는 것을 TS 컴파일러에게 알려준다.

     

    해당 인터페이스는 키가 문자열이고, 값이 숫자인 객체를 정의한다. 

     

    즉, 앞으로 이 모듈에서 자동 증가되는 필드의 이름이 key가 된다.

    export const autoIncrement = (field: string) => {}

    이 라인은 'autoIncrement'라는 함수를 정의하고 내보내는 역할을 한다.

     

    해당 함수는 매개변수로 문자열인 필드를 받으며, 필드 이름은 이후에 자동 증가를 적용할 필드를 지정할 때 사용된다.

    return async function (this: Document & IAutoIncrement, next: () => void) {}

    이 함수는 몽구스의 'pre' 미들웨어에서 호출되는 비동기 함수를 반환한다.

     

    여기서 'this'는 현재 몽구스 도큐먼트와 'IAutoIncrement' 인터페이스를 가리키며

     

    next()는 다음 미들웨어 함수를 호출하는 콜백이다.

    if (this.isNew) {}

    만약 현재 만들어진 도큐먼트가 새로 만들어진 문서라면 다음 로직을 실행한다.

          const Model = this.constructor as Model<Document & IAutoIncrement>;

    현재 문서의 생성자를 가져와서 몽구스 모델로 변환한다.

     

    추가로, TS의 모든 객체는 'constructor'라는 속성을 가지고 있으며, 

     

    이 속성은 해당 객체를 생성한 생성자 함수를 참조한다.

     

    몽구스의 'Document'는 디비에 들어있는 각 도큐먼트를 나타내며, 이는 특정 모델(Feed, User)의 객체이다.

     

    이 객체는 해당 모델의 생성자 함수를 통해 생성되며, 'this.constructor'는 현재 몽구스 Document를 생성한

     

    생성자 함수, 즉 해당 도큐먼트의 모델을 참조하게 된다.

     

    계속해서 'as Model<Document & IAutoIncrement>'은 해당 생성자 함수를 몽구스의 'Model' 타입으로 캐스팅한다.

     

    이를 통해 이후의 로직에서 'Model'의 메서드를 사용할 수 있게 된다.

     

    여기서 '<Document & IAutoIncrement>'는 해당 모델이 관리하는 Document의 타입이며,

     

    이 모델의 Document는 몽구스의 기본 'Document'와 위에서 정의한 'IAutoIncrement'를 모두 포함하게 된다.

          const lastDoc: (Document & IAutoIncrement) | null = await Model.findOne({}, {}, { sort: { [field]: -1 } });

    현재 모델에서 가장 마지막 도큐먼트를 찾는다. 'sort'를 이용해 주어진 필드를 기준으로 역순으로 정렬하고,

     

    그 결과에서 가장 첫 번째 문서를 가져온다.

          if (lastDoc && lastDoc[field]) {
            this[field] = lastDoc[field] + 1;
          } else {
            this[field] = 1;
          }

    가장 마지막 도큐먼트가 존재하고, 해당 도큐먼트에 특정 필드가 존재한다면,

     

    현재 불러온 모델(this)의 필드 값을 1 증가시킨다.

     

    만약 그렇지 않다면 현재 모델의 필드 값을 1로 설정한다.

        }
        next();
      };
    };

    다음 미들웨어 함수를 호출한다.

     

    이렇게 자동 증가 로직을 모듈로 분리하면, 중복 코드를 줄이고 가독성 좋은 코드를 작성할 수 있다.

     

    사용법은 이어지는 글에서 나온다.

     

    /src/models

     

    /Feed.ts

     

    import mongoose, { Schema, Document } from 'mongoose';
    import { autoIncrement } from '../middleware/autoIncrement';
    
    interface IFeed extends Document {
      feedSeq: number;
      title: string;
      body: string;
    }
    
    const feedSchema: Schema<IFeed> = new Schema(
      {
        feedSeq: { type: Number, unique: true, required: true, default: 1 },
        title: { type: String, required: true },
        body: { type: String, required: true },
      },
      {
        timestamps: true,
      }
    );
    
    feedSchema.pre('save', autoIncrement('feedSeq'));
    
    const Feed = mongoose.model<IFeed>('Feed', feedSchema);
    
    export default Feed;

    이 모델은 거의 바뀐 것이 없다. 그러나 미들웨어를 독립시킨 후 불러서 사용하기 때문에

     

    코드의 길이가 줄고 가독성이 상승했다. 달라진 부분에 대해 정리해 보자.

    import { autoIncrement } from '../middleware/autoIncrement';

    방금 위에서 작성한 autoIncrement 메서드를 불러온다.

    feedSchema.pre('save', autoIncrement('feedSeq'));

    해당 메서드를 pre() 함수의 매개변수로 넣어 'save'가 실행되기 전에 feedSeq를 자동 증가 시킨다.

    const Feed = mongoose.model<IFeed>('Feed', feedSchema);

    기존의 코드에는 <IFeed>가 존재하지 않았는데, 이런 식으로 구현해도 feedSchema를 Feed로 등록하게 된다.

     

    하지만 데이터 구조에 대한 타입 정보를 TS에게 알려주지 않는다.

     

    반면 <IFeed>를 명시적으로 작성하면 TS에게 해당 모델의 문서가 IFeed라는 인터페이스의 데이터 구조를

     

    가지고 있음을 TS에게 알려주는 역할을 한다. 이는 TS의 타입 체크 기능을 사용할 수 있게 해 주기 때문에,

     

    코드의 안정성을 높일 수 있다.

     

    User.ts

     

    계속해서 새로 추가된 유저 모듈에 대해서 살펴보자.

    import mongoose, { Schema, Document } from 'mongoose';
    import { autoIncrement } from '../middleware/autoIncrement';
    import bcrypt from 'bcryptjs';
    
    interface IUser extends Document {
      userSeq: number;
      id: string;
      password: string;
      name: string;
      matchPassword: (enteredPassword: string) => Promise<boolean>;
    }
    
    const userSchema: Schema<IUser> = new Schema(
      {
        userSeq: { type: Number, unique: true, required: true, default: 1 },
        id: {
          type: String,
          unique: true,
          required: true,
          match: [/^([\w-.]+@([\w-]+\.)+[\w-]{2,4})?$/, '올바른 이메일 형식을 입력해 주세요.'],
        },
        password: {
          type: String,
          required: true,
          validate: {
            validator: function (v: string) {
              return /^(?=.*[A-Za-z])(?=.*\d).{6,}$/.test(v);
            },
            message: (props) => `${props.value} 는 문자, 숫자를 포함해 6글자 이상이어야 합니다.`,
          },
        },
        name: { type: String, required: true },
      },
      {
        timestamps: true,
      }
    );
    
    userSchema.pre('save', autoIncrement('userSeq'));
    
    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);
      next();
    });
    
    userSchema.methods.matchPassword = async function (enteredPassword: string) {
      return await bcrypt.compare(enteredPassword, this.password);
    };
    
    const User = mongoose.model<IUser>('User', userSchema);
    
    export default User;

    먼저 요약하면, 이 모듈에서는 아이디를 이메일로, 비밀번호를 문자, 숫자를 포함한 6자 이상으로 유효성 검증을 하고,

     

    bcrypt를 이용해 비밀번호를 암호화한 뒤 저장한다.

    import mongoose, { Schema, Document } from 'mongoose';
    import { autoIncrement } from '../middleware/autoIncrement';
    import bcrypt from 'bcryptjs';

    위의 두 import는 여러 번 설명했으니 넘어가고,

     

    여기선 추가로 암호 해싱을 위한 bcrypt를 가져온다.

     

    이를 위해 bcryptjs를 설치해야 하는데, 타입스크립트 버전까지 총 두 개를 설치해야 한다.

    npm install bcryptjs
    npm install --save-dev @types/bcryptjs
    interface IUser extends Document {
      userSeq: number;
      id: string;
      password: string;
      name: string;
      matchPassword: (enteredPassword: string) => Promise<boolean>;
    }

    'Document'를 확장해 IUser 인터페이스를 정의한다.

     

    필요한 필드와 그 타입뿐 아니라, 비밀번호 검증에 필요한 matchPassword 메서드도 함께 정의하고 있다.

    const userSchema: Schema<IUser> = new Schema(
      {
        userSeq: { type: Number, unique: true, required: true, default: 1 },
        id: {
          type: String,
          unique: true,
          required: true,
          match: [/^([\w-.]+@([\w-]+\.)+[\w-]{2,4})?$/, '올바른 이메일 형식을 입력해 주세요.'],
        },
        password: {
          type: String,
          required: true,
          validate: {
            validator: function (v: string) {
              return /^(?=.*[A-Za-z])(?=.*\d).{6,}$/.test(v);
            },
            message: (props) => `${props.value} 는 문자, 숫자를 포함해 6글자 이상이어야 합니다.`,
          },
        },
        name: { type: String, required: true },
      },
      {
        timestamps: true,
      }
    );

    IUser 인터페이스를 바탕으로 userSchema를 생성한다.

     

    각 필드의 타입과 속성, 그리고 유효성 검증 로직이 포함된 것을 확인할 수 있다.

     

    그런데 코드를 잘 보면, 아이디(이메일)와 비밀번호 유효성 검증을 각각 다른 방법으로 한 것을 볼 수 있는데,

     

    두 방법의 차이와 사용법을 정리해 두기 위해 굳이 다른 방법을 사용한 것이다.

     

    둘 다 같은 방법으로 유효성 검증을 해도 아무런 무리가 없다.

     

    참고로 위 비밀번호는 숫자와 문자가 반드시 포함된 6자 이상의 비밀번호를 요구하며, 특수문자는 있어도 되고 없어도 된다.

     

    match

     

    match 키는 필드 값이 주어진 정규식과 일치하는지 검증한다.

     

    여기서 내가 사용하고 있는 정규식은 '/^([\w-.]+@([\w-]+\.)+[\w-]{2,4})?$/'로,

     

    올바른 이메일 형식인지 확인하는 로직이 된다.

     

    만약 유효성 검증에 실패하면 정규식 뒤에 적어둔 메시지가 출력된다.

     

    validate

     

    이 키는 좀 더 복잡한 유효성 검사를 수행하는 데 사용된다.

     

    'validator' 함수를 통해 필드의 값을 받아 주어진 조건(여기서는 정규식)과 비교하는데,

     

    이 함수는 반환값을 true 혹은 false로 가진다.

     

    만약 함수가 false를 반환하면 message 속성에 지정된 에러 메시지가 반환된다.

     


    userSchema.pre('save', autoIncrement('userSeq'));

    Feed 모듈과 마찬가지로 userSeq가 자동 증가하도록 미들웨어 함수를 호출한다.

    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);
      next();
    });

    'save' 메서드가 호출되기 전에 실행될 미들웨어를 추가로 설정한다.

     

    해당 메서드는 비밀번호가 변경되었을 때만 작동하며, 비밀번호를 해싱한다.

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

    userSchema의 메서드로 matchPassword를 정의한다.

     

    이 함수는 입력받은 비밀번호와 저장된 비밀번호를 해싱해 비교해 일치하는지 확인한다.

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

    역시 Feed 모듈과 마찬가지로 <IUser>를 이용해 안전하게 User 모델을 생성한다.

    export default User;

    기본 내보내기로 내보낸다.

     

    /src/utils

     

    /generateToken

     

    계속해서 JWT를 발급하는 유틸 모듈을 구성하자.

    import jwt from 'jsonwebtoken';
    
    const generateToken = (userSeq: number, name: string) => {
      return jwt.sign({ userSeq, name }, process.env.JWT_SECRET!, {
        expiresIn: '30d',
      });
    };
    
    export default generateToken;

    먼저 jwt를 사용하기 위해 'jsonwebtoken'을 설치하고 불러온다.

    npm install jsonwebtoken
    npm install @types/jsonwebtoken

    설치하고 불러왔으면 다음 코드를 읽어보자.

    const generateToken = (userSeq: number, name: string) => {
      return jwt.sign({ userSeq, name }, process.env.JWT_SECRET!, {
        expiresIn: '30d',
      });
    };

    여기선 generateToken이라는 메서드를 정의한다. 해당 메서드는 userSeq와 name을 매개변수로 받아

     

    토큰을 생성하고 그 유효기간을 정하는 역할을 한다.

     

    jwt.sign의 첫 번째 인자로는 토큰에 포함될 페이로드인 { userSeq, name }를 전달하고,

     

    두 번째 인자로는 서명에 사용할 JWT_SECRET을 환경 변수에서 불러와 전달한다.

     

    여기서 userSeq와 name은 내가 임의로 정한 것이고, 어느 값이건 모델에 정의된 값이면 사용할 수 있다.

     

    세 번째 인자로는 토큰의 옵션을 결정하는 객체를 넣어주는데, 여기서는 expiresIn만 사용해서

     

    JWT의 유효기간을 30일로 정해 준다.

    export default generateToken;

    마지막으로 해당 메서드를 기본 내보내기로 내보내준다.

     

    /src/controllers

     

    feedController.ts

     

    import { Request, Response } from 'express';
    import asyncHandler from 'express-async-handler';
    import Feed from '../models/Feed';
    
    const createFeed = asyncHandler(async (req: Request, res: Response) => {
      const newFeed = new Feed(req.body);
      const savedFeed = await newFeed.save();
      res.json(savedFeed);
    });
    
    const getFeedByFeedSeq = asyncHandler(async (req: Request, res: Response) => {
      const feedSeq = req.params.feedSeq;
      const feed = await Feed.findOne({ feedSeq: feedSeq });
      if (feed) {
        res.json(feed);
      } else {
        res.status(404).json({ error: `${feedSeq} 시퀀스에 해당하는 피드가 없습니다.` });
        return;
      }
    });
    
    const getAllFeeds = asyncHandler(async (req: Request, res: Response) => {
      const feeds = await Feed.find({});
      res.json(feeds);
    });
    
    const updateFeed = asyncHandler(async (req: Request, res: Response) => {
      const feedSeq = Number(req.params.feedSeq);
      const feed = await Feed.findOne({ feedSeq: feedSeq });
    
      if (feed) {
        feed.title = req.body.title;
        feed.body = req.body.body;
    
        const updatedFeed = await feed.save();
        res.json(updatedFeed);
      } else {
        res.status(404).json({ error: `${feedSeq} 시퀀스에 해당하는 피드가 없습니다.` });
        return;
      }
    });
    
    const deleteFeed = asyncHandler(async (req: Request, res: Response) => {
      const feedSeq = Number(req.params.feedSeq);
      const feed = await Feed.findOne({ feedSeq: feedSeq });
    
      if (feed) {
        await Feed.deleteOne({ feedSeq: feedSeq });
        res.status(200).json({ message: '삭제 완료' });
      } else {
        res.status(404).json({ error: `${feedSeq} 시퀀스에 해당하는 피드가 없습니다.` });
        return;
      }
    });
    
    export { createFeed, getFeedByFeedSeq, getAllFeeds, updateFeed, deleteFeed };

    먼저 feedController이다.

     

    지난번 글에서 생략했던 메서드와 필요한 에러 처리를 해주었다.

     

    위와 같이 에러메시지를 출력하는 경우, 그리고 에러메시지가 출력되면 로직이 멈추길 원한다면

     

    가장 마지막에 return;을 추가해 작동을 멈춰주어야 한다.

     

    또한 추가로

    import asyncHandler from 'express-async-handler';

    를 가져와 메서드를 감싸주었는데, 우선 해당 라이브러리를 설치하고 가져와야 한다.

    npm install express-async-handler

    이 라이브러리는 타입스크립트를 위한 추가 정의가 필요하지 않다.

     

    위와 같이 설치한 후 모든 메서드를 async/handler로 감싸주었는데, 그 이유는 아래와 같다.

     

    Express 프레임워크에선 여태 봤듯이 함수의 작동 전후로 원하는 작업을 수행할 수 있다.

     

    그러나 미들웨어 함수 내부에서 비동기 함수를 사용하면 에러 핸들링이 어려운데,

     

    내부에서 에러가 발생해도 Express는 알지 못하고 요청이 열려 있는 상태가 될 가능성이 생긴다.

     

    'express-async-handler'는 이런 문제를 해결하는 데 사용되며, 비동기 함수 내부에서 발생하는 에러를

     

    Express가 알 수 있도록 해 준다.

     

    따라서 이 패키지를 사용하면 비동기 함수에서 발생하는 에러를 쉽게 처리하고,

     

    요청을 정상적으로 종료할 수 있게 해준다.

     

    마지막으로, 이 함수를 제대로 사용하기 위해선 Express 앱(server.ts)에 미들웨어 함수를 정의해주어야 하는데,

     

    먼저 해결하고 싶으면 스크롤을 내려서 확인하도록 하자.

     

    /userController.ts

     

    계속해서 유저 컨트롤러이다.

    import { Request, Response } from 'express';
    import asyncHandler from 'express-async-handler';
    import User from '../models/User';
    import generateToken from '../utils/generateToken';
    
    const registerUser = asyncHandler(async (req: Request, res: Response) => {
      const { id, password, name } = req.body;
    
      const userExists = await User.findOne({ id });
    
      if (userExists) {
        res.status(400);
        throw new Error('이미 존재하는 ID 입니다.');
      }
    
      const newUser = new User({ id, password, name });
      const savedUser = await newUser.save();
    
      if (savedUser) {
        res.status(200).json({
          userSeq: savedUser.userSeq,
          id: savedUser.id,
          name: savedUser.name,
          token: generateToken(savedUser.userSeq, savedUser.name),
        });
      } else {
        res.status(400).json({ error: '잘못된 사용자 데이터 입니다.' });
        return;
      }
    });
    
    const loginUser = asyncHandler(async (req: Request, res: Response) => {
      const { id, password } = req.body;
    
      const user = await User.findOne({ id });
    
      if (user && (await user.matchPassword(password))) {
        res.status(200).json({
          userSeq: user.userSeq,
          id: user.id,
          name: user.name,
          token: generateToken(user.userSeq, user.name),
        });
      } else {
        res.status(401);
        throw new Error('이메일 혹은 비밀번호를 잘못 입력하셨습니다.');
      }
    });
    
    export { registerUser, loginUser };

    사용자 정보 수정과 삭제, 조회등은 다음 글로 넘기고 여기서는 간단한 회원가입과 로그인 정도만 구현했다.

    import { Request, Response } from 'express';
    import asyncHandler from 'express-async-handler';
    import User from '../models/User';
    import generateToken from '../utils/generateToken';

    피드 컨트롤러와 거의 비슷하지만, JWT 발급을 위한 generateToken을 가져오는 것을 볼 수 있다.

    const registerUser = asyncHandler(async (req: Request, res: Response) => {});

    회원 가입, 즉 사용자 등록을 처리하는 비동기 메서드이다. 역시 asyncHandler로 감싸져 있다.

      const { id, password, name } = req.body;

    HTTP 요청 바디에서 id, password, name을 추출한다.

      const userExists = await User.findOne({ id });

    같은 아이디(이메일)를 가진 사용자가 DB에 존재하는지 확인한다.

      if (userExists) {
        res.status(400);
        throw new Error('이미 존재하는 ID 입니다.');
      }

    같은 이메일을 가진 사용자가 DB에 있다면 에러코드 400과 함께 에러를 발생시킨다.

     

    여기서는 이전에 했듯이 에러코드와 json을 반환하는 게 아니라 에러를 던지는 것을 확인할 수 있는데,

     

    두 방법의 차이에 대해서 조금 정리하고 가자.

    res.status(400).json({ error: '에러 메시지' });

    피드 컨트롤러에서 썼던 위 방식은 HTTP 상태 코드를 정하고 JSON 형식의 응답 데이터를 클라이언트에게 보낸다.

     

    따라서 미들웨어 내부에서 바로 에러를 처리할 수 있어 간결하고 명시적이라는 장점이 있다.

     

    그러나 위 방법, 혹은 .json이 아닌 .send로 에러메시지 처리를 하는 경우엔 해당 함수의 실행이 끝나지 않는다.

     

    따라서 에러메시지를 전달해 놓고 이후의 로직을 그냥 실행해 버리는 일이 생긴다.

     

    이를 방지하기 위해 위 피드 컨트롤러에서 봤듯이 return;을 따로 추가해 코드의 흐름을 멈춰주는 것이 필요하다.

    res.status(400); throw new Error('에러 메시지');

    반면 위와 같이 에러를 던지는 경우, 코드의 실행이 멈춘다. 즉, 그 이후의 코드는 실행되지 않는다.

     

    이는 throw 문이 호출된 시점에서 즉시 실행을 중지하고 에러를 전파하기 때문이다.

     

    이 에러는 보통 Express의 기본 핸들러, 혹은 커스텀 핸들러에게 잡혀 처리된다.

     

    또한 위와 같은 방식으로 처리하면 중앙화된 에러 핸들러에서 처리할 수 있으므로 

     

    코드가 깔끔해지며 재사용성이 증가하게 된다.

     

    이 두 방법 중 어느 것을 사용하느냐 하는 것은 개발자마다, 프로젝트마다 다르다.

     

    그러나 중요한 것은 에러 처리의 일관성이 있는 것이 좋은 코드라는 사실이다.

     

    나도 아마 다음 글에선 에러처리를 하나로 통일한 코드를 들고 올 것이다.

     

    계속해서 코드를 읽자.

      const newUser = new User({ id, password, name });
      const savedUser = await newUser.save();

    새로운 사용자를 생성하고 디비에 저장한다. 이 과정에서 자동 증가 로직이 실행된다.

      if (savedUser) {
        res.status(200).json({
          userSeq: savedUser.userSeq,
          id: savedUser.id,
          name: savedUser.name,
          token: generateToken(savedUser.userSeq, savedUser.name),
        });
      } else {
        res.status(400).json({ error: '잘못된 사용자 데이터 입니다.' });
        return;
      }

    사용자 정보가 성공적으로 저장되었다면,

     

    userSeq, id, name, (generateToken으로 생성한) token을 JSON 형식으로 반환한다.

     

    그렇지 않다면 400 코드와 함께 에러를 발생시킨다.

     

    여기서 다소 흥미로운 부분은, 400 에러 이후에 실행될 코드가 없음에도 return을 붙여주었다는 것이다.

     

    이는 코드의 가독성이나 일관성을 위한 일종의 컨벤션일 뿐 없어도 되는 명령어이다.

    const loginUser = asyncHandler(async (req: Request, res: Response) => {});

    계속해서 로그인을 처리하는 비동기 함수를 정의한다.

      const { id, password } = req.body;
    
      const user = await User.findOne({ id });

    HTTP 요청 본문에서 id와 password를 추출하고, 해당 id를 가진 사용자를 디비에서 조회한다.

      if (user && (await user.matchPassword(password))) {
        res.status(200).json({
          userSeq: user.userSeq,
          id: user.id,
          name: user.name,
          token: generateToken(user.userSeq, user.name),
        });
      } else {
        res.status(401);
        throw new Error('이메일 혹은 비밀번호를 잘못 입력하셨습니다.');
      }

    만약 사용자 정보가 있고, 동시에 matchPassword의 값이 true라면

     

    200 코드와 함께 사용자 정보 및 토큰을 JSON 바디에 담아서 반환한다.

     

    그렇지 않다면 401 코드와 함께 에러를 던진다.

    export { registerUser, loginUser };

    두 메서드를 내보내준다.

     

    /src/routes

     

    /feedRoutes.ts

     

    import express from 'express';
    import { createFeed, getFeedByFeedSeq, getAllFeeds, updateFeed, deleteFeed } from '../controllers/feedController';
    
    const router = express.Router();
    
    router.post('/', createFeed);
    router.get('/:feedSeq', getFeedByFeedSeq);
    router.get('/', getAllFeeds);
    router.patch('/:feedSeq', updateFeed);
    router.delete('/:feedSeq', deleteFeed);
    
    export default router;

    추가된 메서드를 위한 라우터를 추가했다.

     

    /userRoutes.ts

     

    import express from 'express';
    import { registerUser, loginUser } from '../controllers/userController';
    
    const router = express.Router();
    
    router.route('/').post(registerUser);
    router.route('/login').post(loginUser);
    
    export default router;

    역시 정의한 메서드에 따라 라우터를 추가했다.

     

    /src/server.ts

     

    import express, { NextFunction, Request, Response } from 'express';
    import dotenv from 'dotenv';
    import connectDB from './config/db';
    import userRoutes from './routes/userRoutes';
    import feedRoutes from './routes/feedRoutes';
    import morgan from 'morgan';
    
    dotenv.config();
    
    connectDB();
    
    const app = express();
    const port = process.env.PORT || 3000;
    
    app.use(express.json());
    app.use(morgan('dev'));
    
    app.use('/users', userRoutes);
    app.use('/feeds', feedRoutes);
    
    app.get('/', (req, res) => {
      res.send('Hello World!');
    });
    
    app.use((err: any, req: Request, res: Response, next: NextFunction) => {
      res.status(err.status || 500).send({ message: err.message });
    });
    
    app.listen(port, () => {
      console.log(`Server is running at http://localhost:${port}`);
    });

    여태까지의 모든 변경사항이 반영된 애플리케이션 코드이다.

     

    추가된 부분만 하나씩 보자.

    import userRoutes from './routes/userRoutes';

    유저 컨트롤러 라우팅을 위해 라우트 함수를 가져온다.

    app.use('/users', userRoutes);

    유저 라우트 함수를 애플리케이션에 등록하고, 기본 엔드포인트를 지정한다.

    app.use((err: any, req: Request, res: Response, next: NextFunction) => {
      res.status(err.status || 500).send({ message: err.message });
    });

    위에서 도입한 asyncHandler를 사용하기 위한 미들웨어이다.

     

    일반적으로 이 미들웨어는 모든 라우트 핸들러 뒤에, Express 앱의 가장 뒤에 위치해야 한다.

     

    추가로 이 미들웨어는 Express 애플리케이션에서 발생하는 에러를 캐치하고, 

     

    클라이언트에 500, 혹은 적절한 에러 메시지와 HTTP 상태 코드를 반환한다.

     

    이렇게 구성하고 서버와 디비를 켜고 테스트해 보면 회원가입과 로그인, 피드 CRUD가 잘 작동하는 것을 확인할 수 있다.

     

    다음 글에서는 Comment 모델과 관련 로직을 추가하고, 게시글 작성, 수정, 삭제에 로그인 정보를 담아보겠다.

     

    슬슬 그럴듯한 게시판에 가까워진다.

     

    끝!

    반응형
    댓글
    공지사항
    최근에 올라온 글
    최근에 달린 댓글
    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
    글 보관함