티스토리 뷰

728x90
반응형

목차

     

     

    지난번 글에선 프로젝트 기본 설정과 프레임워크 설정,

     

    라이브러리 설치와 디비 연결까지 기초적인 단계에서 구현했다.

     

    오늘은, 거기에서 조금 더 나가, 프로젝트의 폴더 구성과 기본적인 CRUD를 구현해 보도록 하겠다.

     

    물론 아키텍처는 제목에 쓰여있듯이 MVC를 사용한다.

     

    Directory Layout

     

    노드의 MVC 패턴에선 기본적으로 아래와 같은 디렉토리 구조를 사용한다.

     

    각 폴더의 설명을 짧게 하자면 아래와 같다.

     

    • config: db 연결을 비롯한 기본 설정이 관리되는 폴더
    • controllers: 로직 데이터의 입출력 및 적절한 모델을 호출하는 클래스를 가지는 폴더
    • middleware: 인증, 로깅 등 서비스 전반에 걸쳐 사용되는 미들웨어 폴더
    • models: 객체를 생성할 정보와 타입 등을 담은 클래스를 가지는 폴더
    • routes: 요청을 엔드포인트로 라우팅 해주는 역할을 하는 클래스를 모아둔 폴더
    • utils: 재사용이 가능한 유틸리티 함수를 담을 클래스를 모아둔 폴더

    추가로 server.ts는 이전 글에서 app.ts에서 서버 실행 부분을 분할해 만든 모듈이며,

     

    나머지 절반은 db.ts로 들어갔고, 구현은 곧 등장한다.

     

    이어서 .env는 환경 변수를 관리하는 파일이다.

     

    또한 ESLint와 Prettier 설정도 생략했는데, 필요하다면 아래 글을 참고하면 된다.

     

    마지막으로, 이번 글에서는 config, controllers, models, routes 이렇게 네 가지 폴더에서만 작업한다.

     

    따라서 middleware, utils 폴더는 아직 만들지 않아도 상관없다.

     

    .env & .gitignore

     

    환경 변수를 먼저 설정하자.

     

    .env는 스프링에서 했던 것처럼 프로젝트 전역에서 사용할 변수를 관리하는 파일이며,

     

    계정 비밀번호 등 보안에 중요한 정보가 담길 수 있기 때문에 보통은 .gitignore에 등록해 레포지토리로의 업로드를 막는다.

     

    지금 우리에게 필요한 건 서버의 포트와 디비 주소 정보이기 때문에 다소 아담하게 작성한다.

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

    여기까지 작성했으면, 프로젝트에서 .env 환경 변수를 읽어와 사용하도록 해주는 dotenv 패키지를 설치한다.

    npm install dotenv

    dotenv는 자체적으로 타입스크립트를 지원하기 때문에 따로 더 설치할 것은 없다.

     

    이어서 .gitignore 파일이다. 설명은 생략한다.

    # .gitignore
    node_modules/
    npm-debug.log
    .DS_Store
    *.log
    *.csv
    *.dat
    *.out
    *.pid
    *.gz
    
    pids
    logs
    results
    
    .env
    
    public
    dist
    
    # IDE / editor specific
    .vscode
    .idea
    *.sw*

    .env가 제대로 들어있는지만 확인하면 된다.

     

    /src/config/db.ts

     

    위에서 언급했듯, 지난번 글의 app.ts에서 디비 연동 부분만 떼어서 구현한 모듈이다.

     

    또한 방금 작성한 .env 파일과 설치한 dotenv를 이용해 환경변수를 가져와서 사용하겠다.

     

    구현은 아래와 같다.

    import mongoose from 'mongoose';
    import dotenv from 'dotenv';
    
    dotenv.config();
    
    const connectDB = async () => {
      try {
        await mongoose.connect(process.env.DB_URL!);
    
        console.log(`MongoDB connected: ${process.env.DB_URL}`);
      } catch (error) {
        console.error(`Error: ${(error as Error).message}`);
        process.exit(1);
      }
    };
    
    export default connectDB;

    코드의 각 줄에 대해 짧게 설명하자면

    import mongoose from 'mongoose';
    import dotenv from 'dotenv';
    
    dotenv.config();

    MongoDB와 소통을 위한 node.js 기반 ODM 라이브러리 'mongoose'와 방금 설치한 'dotenv' 라이브러리를 가져온다.

     

    이어서 'dotenv.config()'를 사용해 dotenv의 config 함수를 불러 .env 파일의 내용을 process.env에 할당한다.

    const connectDB = async () => {
      try {
        await mongoose.connect(process.env.DB_URL!);
    
        console.log(`MongoDB connected: ${process.env.DB_URL}`);

    계속해서 비동기(async) 함수 connectDB를 선언한다. Const를 이용한 함수 선언은 가독성 측면에서 유리하다.

     

    그다음 mongoose의 'connect' 함수를 호출해 몽고디비로 연결을 시도(try) 한다.

     

    await 키워드는 Promise가 완료될 때까지 함수 실행을 일시적으로 중지시키는 역할을 한다.

     

    여기서 Promise란 async()가 붙은 함수의 반환이라 생각하면 되기 때문에, 비동기 함수를 동기함수처럼 다룰 수 있게 된다.

     

    추가로 connect 함수의 매개변수로 주어진 process.env.DB_URL! 에서 느낌표(!)는

     

    Non-null 단언 연산자라 불리며, 앞에 주어진 DB_URL이라는 환경 변수가 null 또는 undefined가 아님을 명시적으로 알려준다.

     

    마지막으로 연결에 성공했을 때, 콘솔에 URL을 출력한다.

      } catch (error) {
        console.error(`Error: ${(error as Error).message}`);
        process.exit(1);
      }
    };

    만약 try 블록 내의 로직시 실패해서 DB 연결이 실패했을 경우, 'catch' 블록이 예외를 처리한다.

     

    여기서 'error'는 발생한 예외 객체를 참조한다.

     

    예외 처리 방법은 콘솔에 메시지를 출력하는 것인데, 여기서 '(error as Error)'는 타입 단언이라 불리며,

     

    개발자가 특정 변수의 타입을 정확하게 알고 있을 때 사용하며, error를 Error타입으로 간주하도록 TS에게 지시한다.

     

    마지막으로 'process.exit(1)'은 에러코드 1과 함께 프로세스를 종료시키는 것을 나타낸다.

     

    에러코드 1은 비정상적인 종료를 의미한다.

    export default connectDB;

    이렇게 정의한 connectDB 함수를 'default'로 내보낸다.

     

    이렇게 내보내진 함수는 다른 모듈에서 불러 사용할 수 있으며,

     

    특히 기본 내보내기는 모듈당 하나만 존재하고 다른 모듈에서 불러올 때 '{}' 없이 이름을 자유롭게 지정할 수 있다.

     

    /src/models/Feed.ts

     

    _id

     

    들어가기 전에, 몽고디비는 컬렉션에 도큐먼트가 생성될 때마다 자동으로 생성되는 고유한 필드로,

     

    ObjectId라는 특수한 데이터 타입을 가진다.

     

    이는 도큐먼트의 생성시점, 프로세스, 머신 등을 구분할 수 있게 해주는 좋은 식별자이지만,

     

    여러 가지 이유로 별개의 PK를 만들어 컬렉션을 관리하는 것이 편할 때가 있다.

     

    자세한 글은 아래에 정리되어 있고, 나는 여기서 아래 글을 응용해 'feedSeq'라는 자동 증가 필드를 PK로 쓰겠다.

     

    [Database]mongoDB의 '_id', 또는 auto-increment ID

     

    [Database]mongoDB의 '_id', 또는 auto-increment ID

    목차 _id 몽고디비는 컬렉션에 도큐먼트가 생성될 때마다 자동으로 생성되는 고유한 필드로, ObjectId라는 특수한 데이터 타입을 가진다. 이 타입은 12바이트의 숫자, 정확하게는 24글자의 16진수로

    gnidinger.tistory.com

     


    이제 코드를 보자.

    import mongoose, { Schema, Document, Model } from 'mongoose';
    
    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', async function (next) {
      if (this.isNew) {
        const FeedModel = this.constructor as Model<IFeed>;
        const lastFeed = await FeedModel.findOne({}, {}, { sort: { feedSeq: -1 } });
        if (lastFeed) {
          this.feedSeq = lastFeed.feedSeq + 1;
        }
      }
      next();
    });
    
    const Feed = mongoose.model('Feed', feedSchema);
    
    export default Feed;

    도큐먼트 모듈의 첫 글자를 대문자로 적는 몽고디비의 컨벤션에 따라 모델 파일도 대문자로 시작했다.

     

    선 요약 하고 들어가면, 이 코드는 mongoose를 사용해서 몽고디비에 저장될 'Feed' 도큐먼트를 정의한다.

     

    현재는 앞서 말한 'feedSeq' 이외에 'title', 'body'와 명시적으로 적혀있진 않지만 'createdAt', 'updatedAt'이 포함되어 있다.

     

    이어서 코드를 끊어서 분석해 보자.

    import mongoose, { Schema, Document, Model } from 'mongoose';

    여기서는 mongoose와 함께 'Schema', 'Document', 'Model' 세 개의 타입을 명시적으로 가져온다.

     

    물론 이 세 개는 모두 mongoose의 내장 타입이며, 위와 같이 명시적으로 가져오지 않아도 사용할 수 있다.

     

    하지만 이들을 명시적으로 가져오는 것은 TS의 타입 추론 기능을 제대로 활용하기 위함이라는데,

     

    명시적으로 import 할 때는 TS가 이 타입에 대한 정보를 직접 가지고 사용할 수 있기 때문이라고 한다.

    interface IFeed extends Document {
      feedSeq: number;
      title: string;
      body: string;
    }

    먼저 'Document'를 확장한 'IFeed' 인터페이스를 정의한다.

     

    여기서 'Document'란, 몽고디비에서 사용하는 바로 그 도큐먼트가 맞다.

     

    몽구스의 'Document'는 이를 클래스 안의 객체로 다룰 수 있게 해 준다.

     

    즉, 'IFeed'는 몽고디비의 도큐먼트의 속성과 메서드를 모두 사용할 수 있게 된다.

     

    이어서 인터페이스란 스프링에서와 비슷하게 특정 객체의 필드명과 그 타입을 정의한다.

     

    물론 인터페이스 없이 바로 스키마를 작성할 수도 있지만, 인터페이스 사용해 데이터의 형태를 정의하는 것이

     

    코드의 타입 안정성 유지에 도움이 된다.

    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,
      }
    );

    몽고디비는 대표적인 NoSQL로, 스키마가 고정되지 않는 특징을 가진다.

     

    하지만 실제 개발 과정에서는 데이터의 일관성 등을 위해 그 구조를 정의하는 것이 필요한데,

     

    'Schema'는 그와 같은 목적으로 도입되었다.

     

    따라서 'Schema'는 몽고디비의 각 도큐먼트의 모양(Shape)과 데이터 유효성 규칙(Validation)을 정의하며,

     

    이는 어떤 필드가 포함되는지, 그 필드의 타입과 속성은 무엇인지 더 자세하게 정의한다.

     

    여기서는 타입스크립트 제네릭을 이용한 Schema<IFeed>를 이용해 Schema 타입을 IFeed에 특화시킨다.

     

    이렇게 정의하면 Schema는 IFeed 인터페이스의 요구사항에 맞는 요소를 가져야 한다는 것을 TS가 알게 된다.

     

    따라서 명시된 요소 이외의 것을 정의하면 TS가 에러를 발생시키며, 이는 타입 안정성을 증가시킨다.

     

    여기서는 위에 정의한 'feedSeq', 'title', 'body'의 속성과 기본값 등을 정의하고 있으며, 독특한 것은

      {
        timestamps: true,
      }

    이 부분을 추가해 주면 몽구스가 자동으로 'createdAt', 'updatedAt' 필드를 관리하게 된다.

    feedSchema.pre('save', async function (next) {
      if (this.isNew) {
        const FeedModel = this.constructor as Model<IFeed>;
        const lastFeed = await FeedModel.findOne({}, {}, { sort: { feedSeq: -1 } });
        if (lastFeed) {
          this.feedSeq = lastFeed.feedSeq + 1;
        }
      }
      next();
    });

    계속해서 몽구스 스키마에서 지원하는 미들웨어 함수 'pre'를 이용해 'save' 실행 전에 작동하는 함수를 정의한다.

     

    물론 'post'함수도 제공하고 있으며, 이를 이용해 특정 동작 이후에 수행될 미들웨어를 정의한다.

     

    지금 내가 언뜻 보기엔 스프링 AOP와 비슷한 거 아닌가 싶은데.. 좀 더 보면 알게 되겠지.

     

    어쨌거나 위 코드에서는 'save' 이벤트 발생 전에 feedSeq를 자동 증가시켜 주는 역할을 한다.

     

    여기서 'this.constructor'란 현재 도큐먼트가 만들어진 모델, 즉 Feed 모델을 뜻하며

     

    as Model<IFeed>를 통해 해당 모델이 'IFeed'인터페이스를 가진 모델임을 명시하여 TS의 타입 추론 시스템을 도와준다.

     

    추가로 여기서 this.isNew는 현재 다루는 도큐먼트가 새로 생성된 것인지 판단하는 데 사용된다.

     

    마지막에 쓰인 next() 함수는 미들웨어 실행을 제어하는 역할을 하는 함수이다.

     

    그 이름대로 다음 미들함수로 제어권을 넘기며, 이런 식으로 미들웨어 함수가 연쇄적으로 수행되도록 한다.

     

    또한, next() 함수를 호출하지 않으면 현재의 미들웨어 함수에서 제어가 멈추게 되기 때문에

     

    다음 미들웨어, 라우트 핸들러 등이 실행되지 않아 클라이언트에게 응답이 전송되지 않을 가능성이 생긴다.

     

    위 코드에서 말하자면 next()를 호출하지 않으면 'save'이벤트가 완료되지 않고 중단된다.

     

    따라서 미들웨어 함수에서는 일반적으로 next()를 호출하는 것이 좋다.

    const Feed = mongoose.model('Feed', feedSchema);
    
    export default Feed;

    여기서는 'mongoode.model'함수와 'feedSchema'를 사용해서 Feed 모델을 생성한다.

     

    여기서 model 메서드는 스키마를 사용해서 모델을 생성하는 역할을 하며, 위의 Model과는 별개의 기능이다.

     

    이 모델은 기본 내보내기에 의해 모듈 외부로 내보내지며 이후의 데이터 조작(CRUD)에 사용된다.

     

    /src/controllers/feedController.ts

     

    import { Request, Response } from 'express';
    import feed from '../models/Feed';
    
    const createFeed = async (req: Request, res: Response) => {
      const newFeed = new feed(req.body);
      const savedFeed = await newFeed.save();
      res.json(savedFeed);
    };
    
    const getAllFeeds = async (req: Request, res: Response) => {
      const feeds = await feed.find({});
      res.json(feeds);
    };
    
    export { createFeed, getAllFeeds };

    계속해서 컨트롤러 모듈이다. 처음에 적은 대로 로직 데이터의 입출력 및 적절한 모델을 호출하는 목적을 가진다.

    import { Request, Response } from 'express';
    import feed from '../models/Feed';

    가장 먼저 Express 프레임워크에서 제공하는 'Request', 'Response' 객체 타입을 가져온다.

     

    Request 객체는 HTTP 요청에 대한 정보를 담고 있으며, Response 객체는 HTTP 응답을 구성하고 보내는 데 사용된다.

     

    이어서 위에서 생성한 Feed 모델을 가져온다. 이 모델을 통해 디비에서 Feed 문서에 대한 CRUD 작업을 수행할 수 있다.

    const createFeed = async (req: Request, res: Response) => {
      const newFeed = new feed(req.body);
      const savedFeed = await newFeed.save();
      res.json(savedFeed);
    };

    계속해서 요청과 응답을 매개변수로 가지는 비동기 함수 createFeed를 정의한다.

     

    함수 안에서는 먼저 req.body를 사용하여 새 Feed 인스턴스를 생성한다.

     

    이어서 생성된 Feed 객체를 디비에 저장한 뒤 해당 문서를 savedFeed에 할당한다.

     

    이때 'save' 함수는 비동기적으로 작동하기 때문에 await을 사용해서 작업이 끝나는 것을 기다린다.

     

    다음 줄에서는 savedFeed에 할당된 도큐먼트의 값을 JSON 형식으로 응답 객체에 남아 전송한다.

    const getAllFeeds = async (req: Request, res: Response) => {
      const feeds = await feed.find({});
      res.json(feeds);
    };

    이번엔 존재하는 모든 Feed를 조회하는 함수이다. 전체적으로 위의 함수와 비슷해서 딱히 언급할 건 없지만,

     

    몽구스에서 'feed.find({})'와 같이 find 함수의 매개변수로 빈 객체 '{}'를 전달하면 해당 모델에 속하는

     

    모든 도큐먼트를 조회해 온다는 건 새롭다.

     

    추가로, 특정 조건을 가진 도큐먼트를 조회하려면 매개변수를 아래와 같이 주면 된다.

    feed.find({ title: "example" })
    export { createFeed, getAllFeeds };

    마지막으로 정의한 두 개의 함수를 모듈 밖으로 내보낸다.

     

    /src/routes/feedRoutes.ts

     

     

    import express from 'express';
    import { createFeed, getAllFeeds } from '../controllers/feedController';
    
    const router = express.Router();
    
    router.post('/', createFeed);
    router.get('/', getAllFeeds);
    
    export default router;

    이 모듈은 Express 프레임워크에서 라우터를 설정하는 역할을 한다.

     

    엔드포인트와 HTTP 요청에 따른 핸들러메서드를 매칭하는 기능을 한다고도 말할 수 있다.

    import express from 'express';
    import { createFeed, getAllFeeds } from '../controllers/feedController';

    Express 프레임워크와 위에서 작성한 'createFeed', 'getAllFeeds' 함수를 feedController로부터 가져온다.

    const router = express.Router();

    Express의 Router 객체를 생성해 router에 할당한다. 해당 객체를 이용해 라우팅을 구성할 수 있다.

    router.post('/', createFeed);
    router.get('/', getAllFeeds);

    각각 HTTP POST, GET 요청을 '/' 경로로 받으면 'createFeed', 'getAllFeeds'함수를 실행한다.

     

    즉, 클라이언트가 새로운 피드를 생성하거나 모든 피드를 받아오고 싶을 때 해당 라우터가 사용된다.

    export default router;

    라우터를 내보내 다른 파일에서 import 할 수 있도록 한다.

     

    /src/server.ts

     

    마지막으로 모든 것이 반영된 server 모듈이다.

    import express from 'express';
    import dotenv from 'dotenv';
    import connectDB from './config/db';
    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('/feeds', feedRoutes);
    
    app.get('/', (req, res) => {
      res.send('Hello World!');
    });
    
    app.listen(port, () => {
      console.log(`Server is running at http://localhost:${port}`);
    });

    위에서부터 순서대로 dotenv.config();를 사용하여 환경 변수를 사용할 수 있도록 하고

     

    connectDB();로 디비에 연결한 뒤 앱을 생성하며 코드가 진행되는 것을 볼 수 있다.

    import express from 'express';
    import dotenv from 'dotenv';
    import connectDB from './config/db';
    import feedRoutes from './routes/feedRoutes';
    import morgan from 'morgan';

    Express 프레임워크, dotenv 패키지, connectDB 함수, feedRoutes 라우터,

     

    그리고 로깅 미들웨어morgan을 가져온다. 이 미들웨어는 HTTP 요청과 그 처리 로그를 콘솔에 출력하는데 쓰인다.

    dotenv.config();
    
    connectDB();

    시작부에 적었듯이 환경 변수를 사용할 수 있도록 .env 파일을 process.env에 로드하고, 몽고디비와 연결한다.

    const app = express();
    const port = process.env.PORT || 3000;

    express(); 를 이용해 애플리케이션 인스턴스를 생성한다.

     

    이렇게 생성된 app은 HTTP요청을 처리하는데 필요한 라우팅, 미들웨어, 메서드 등을 다양하게 가지고 있다.

     

    이어서 서버가 리스닝할 포트를 설정한다. 환경 변수 PORT가 없다면 기본값으로 3000을 사용한다.

    app.use(express.json());
    app.use(morgan('dev'));
    
    app.use('/feeds', feedRoutes);

    app.use(); 메서드는 미들웨어 함수를 애플리케이션에 등록하는 역할을 한다.

     

    위에서 봤듯이 미들웨어 함수는 req, res, next()를 매개변수로 받으며 각 오브젝트를 조작하거나 코드를 진행시킬 수 있다.

     

    가장 먼저 app.use(express.json());Express 앱에 JSON 파싱 미들웨어를 추가하는 역할을 한다.

     

    해당 미들웨어를 이용해 서버는 JSON 형태의 요청 본문을 처리할 수 있게 된다.

     

    이어서 app.use(morgan('dev'));개발 모드의 로깅을 설정한다. 위에 적었듯 HTTP 요청 정보를 로그로 남긴다.

     

    app.use('/feeds', feedRoutes); 는 '/feeds' 경로에 대한 요청을 'feedRoutes' 라우터로 라우팅 한다.

     

    즉, 새로운 피드를 생성하기 위해서는 응답 바디를 JSON 형식으로 담아 '/feeds'에 POST요청을 하면 된다.

    app.get('/', (req, res) => {
      res.send('Hello World!');
    });

    '/' 경로에 대한 GET 요청을 처리하는 라우트를 추가한다. 기본 경로에 GET 요청을 보냈을 때의 응답이라고 보면 된다.

     

    물론 app.get()뿐 아니라 app.post(), app.put(), app.delete()등도 존재한다.

    app.listen(port, () => {
      console.log(`Server is running at http://localhost:${port}`);
    });

    app.listen() 메서드는 Express 앱이 지정된 포트에서 HTTP 요청을 기다리도록 한다.

     

    여기서는 위에서 설정한 port 값(3000)에서 리스닝을 시작하며, 서버가 정상적으로 실행되면

     

    console.log()를 이용해 콘솔에 메시지를 출력한다.

     


    이렇게 해서 가장 기초적인 구현이 끝났다.

     

    물론 게시판 CRUD도 제대로 완성되어있지 않고, 미들웨어 모듈도 분리하지 않았지만

     

    이대로도 게시판에 글을 올리고 읽어오는 것은 할 수 있다.

     

    다음 글에선 피드 수정과 삭제, 개별 조회는 생략하고 바로 User 모듈과 로그인, JWT 발급 등을 구현할 예정이다.

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