티스토리 뷰

728x90
SMALL

배경

프로젝트를 시작할 때 가장 먼저 하는 것은 프로젝트 구조를 짜는 것이다. 특히 팀프로젝트를 하게 되면 이 부분에서 충돌이 생기기도 한다. 주니어 수준에서 무엇이 옳은지 판단하는 것은 쉽지 않다. 시니어 개발자라고 할지라도 당연히 프로젝트가 어떤 프로젝트인지에 따라서 그리고 어떤 목적을 가지고 진행하는지에 따라 구조가 달라진다. 나는 정호영님이 추천한 깃허브를 보고 참고를 많이 했다.

https://github.com/santiq/bulletproof-nodejs

공통스타일

prettier

먼저 코딩 스타일을 맞춰야 한다. 혼자서 프로젝트를 하더라도 prettier정도는 적용하는 것을 추천한다. 기본 vscode에 설정할수도 있지만 여러명이서 작업할 때는 아래와같이 루트경로에 파일을 만들어준다. 아래는 일반적인 패턴이며 경우에따라 조금씩 달라질 수 있다. 각각에 대한 자세한 설명은 공식사이트에서 찾아보자. 그리고 devDependency에 prettier도 추가해주자. 각자 vscode에 prettier가 존재하겠지만 버전이 바뀔때 문제가 생길수도 있다고 한다.

.prettieric

{
  "singleQuote": true,
  "semi": true,
  "useTabs": false,
  "tabWidth": 2,
  "trailingComma": "all",
  "printWidth": 120,
  "arrowParens": "avoid"
}

린트

린트는 사람에 따라 취향이 나뉘기도 한다. 하지만 린트를 사용하면 확실히 오류를 사전에 잡아준다. 나는 airbnb를 사용하는데 조금 빡빡하다. 그래서 필요없다고 생각하는 기능은 그때마다 꺼두는 편이다. typescript를 사용하지 않는다면 다음과 같이 설치하면된다. 버전은 당연히 계속 업데이트 된다.
npm info "eslint-config-airbnb-base@latest" peerDependencies 를 입력해서 정보를 확인하고 필요한 dependency를 설치하면 된다.

eslint-plugin-prettier버전이 안맞으면 오류가 나기도 한다...  그럴때는 구글링해서 해결하자

 

package.json

  "devDependencies": {
    "eslint": "^7.2.0",
    "eslint-config-airbnb-base": "^14.2.1",
    "eslint-config-prettier": "^8.3.0",
    "eslint-plugin-import": "^2.24.2",
    "eslint-plugin-prettier": "^3.2.0",
    "prettier": "^2.3.2"
  }

typescript를 사용한다면

    "@typescript-eslint/eslint-plugin": "^4.31.1",
    "@typescript-eslint/parser": "^4.31.1",

추가로 설치해주자.

.eslintrc.json(js)

{
  "env": {
    "browser": true,
    "es2021": true,
    "commonjs": true,
    "node": true
  },
  "extends": ["airbnb-base", "plugin:prettier/recommended"],
  "parserOptions": {
    "ecmaVersion": 12,
    "sourceType": "module"
  },
  "plugins": ["prettier"],
  "rules": {
     각자 필요한것들
  }
}

.eslintrc.json(ts)

{
  "env": {
    "browser": true,
    "es2021": true,
    "commonjs": true,
    "node": true
  },
  "extends": ["airbnb-base", "plugin:prettier/recommended"],
  "parser": "@typescript-eslint/parser",
  "parserOptions": {
    "ecmaVersion": 12,
    "sourceType": "module",
    "project": ["server/tsconfig.json"]
  },
  "plugins": ["@typescript-eslint", "prettier"],
  "rules": {
     각자 필요한것들
  }
}

tsconfig

그냥 참고용으로 적어놨다. 타입스크립트 관련된 내용은 다른글에서...

{
  "compilerOptions": {
    /* Visit https://aka.ms/tsconfig.json to read more about this file */

    /* Basic Options */
    // "incremental": true,                         /* Enable incremental compilation */
    "target": "es5" /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', 'ES2021', or 'ESNEXT'. */,
    "module": "commonjs" /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */,
    // "lib": [],                                   /* Specify library files to be included in the compilation. */
    // "allowJs": true,                             /* Allow javascript files to be compiled. */
    // "checkJs": true,                             /* Report errors in .js files. */
    // "jsx": "preserve",                           /* Specify JSX code generation: 'preserve', 'react-native', 'react', 'react-jsx' or 'react-jsxdev'. */
    // "declaration": true,                         /* Generates corresponding '.d.ts' file. */
    // "declarationMap": true,                      /* Generates a sourcemap for each corresponding '.d.ts' file. */
    "sourceMap": true /* Generates corresponding '.map' file. */,
    // "outFile": "./",                             /* Concatenate and emit output to single file. */
    "outDir": "./dist" /* Redirect output structure to the directory. */,
    // "rootDir": "./",                             /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */
    // "composite": true,                           /* Enable project compilation */
    // "tsBuildInfoFile": "./",                     /* Specify file to store incremental compilation information */
    // "removeComments": true,                      /* Do not emit comments to output. */
    // "noEmit": true,                              /* Do not emit outputs. */
    // "importHelpers": true,                       /* Import emit helpers from 'tslib'. */
    // "downlevelIteration": true,                  /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */
    // "isolatedModules": true,                     /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */

    /* Strict Type-Checking Options */
    "strict": true /* Enable all strict type-checking options. */,
    // "noImplicitAny": true,                       /* Raise error on expressions and declarations with an implied 'any' type. */
    // "strictNullChecks": true,                    /* Enable strict null checks. */
    // "strictFunctionTypes": true,                 /* Enable strict checking of function types. */
    // "strictBindCallApply": true,                 /* Enable strict 'bind', 'call', and 'apply' methods on functions. */
    // "strictPropertyInitialization": true,        /* Enable strict checking of property initialization in classes. */
    // "noImplicitThis": true,                      /* Raise error on 'this' expressions with an implied 'any' type. */
    // "alwaysStrict": true,                        /* Parse in strict mode and emit "use strict" for each source file. */

    /* Additional Checks */
    // "noUnusedLocals": true,                      /* Report errors on unused locals. */
    // "noUnusedParameters": true,                  /* Report errors on unused parameters. */
    // "noImplicitReturns": true,                   /* Report error when not all code paths in function return a value. */
    // "noFallthroughCasesInSwitch": true,          /* Report errors for fallthrough cases in switch statement. */
    // "noUncheckedIndexedAccess": true,            /* Include 'undefined' in index signature results */
    // "noImplicitOverride": true,                  /* Ensure overriding members in derived classes are marked with an 'override' modifier. */
    // "noPropertyAccessFromIndexSignature": true,  /* Require undeclared properties from index signatures to use element accesses. */

    /* Module Resolution Options */
    "moduleResolution": "node" /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */,
    "baseUrl": "src" /* Base directory to resolve non-absolute module names. */,
    "paths": {
      "/*": ["/*"]
    } /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */,
    // "rootDirs": [],                              /* List of root folders whose combined content represents the structure of the project at runtime. */
    "typeRoots": ["src/customTypes", "node_modules/@types"] /* List of folders to include type definitions from. */,
    // "types": [],                                 /* Type declaration files to be included in compilation. */
    // "allowSyntheticDefaultImports": true,        /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */
    "esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */,
    // "preserveSymlinks": true,                    /* Do not resolve the real path of symlinks. */
    // "allowUmdGlobalAccess": true,                /* Allow accessing UMD globals from modules. */

    /* Source Map Options */
    // "sourceRoot": "",                            /* Specify the location where debugger should locate TypeScript files instead of source locations. */
    // "mapRoot": "",                               /* Specify the location where debugger should locate map files instead of generated locations. */
    // "inlineSourceMap": true,                     /* Emit a single file with source maps instead of having a separate file. */
    // "inlineSources": true,                       /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */

    /* Experimental Options */
    "experimentalDecorators": true /* Enables experimental support for ES7 decorators. */,
    "emitDecoratorMetadata": true /* Enables experimental support for emitting type metadata for decorators. */,

    /* Advanced Options */
    "skipLibCheck": true /* Skip type checking of declaration files. */,
    "forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */
  },
  "include": ["src/**/*"],
  "exclude": ["**/dist/**", "**/node_modules/**"]
}

구조 생각하기

일단 한곳에 모든 코드가 몰려있는 경우는 좋은 코드가 아니다. 다른 사람이 코드를 알아보기 힘들고(규모가 커지면 자기 코드를 알아보기도 힘들다) 테스트를 할 때도 쉽지 않다. 일단 분리할 수 있는 경우는 최대한 분리를 하자. 마치 함수 하나가 하나의 역할을 가져야 하는 것과 비슷하다.
대부분의 코드들은 보통 sr라는 폴더안에 작성한다. src는 soure의 약어이다. 일단 테스트 코드는 제외하고 생각한다. 테스트는 다른 글에 작성하겠다.

엔트리 포인트

일단 엔트리포인트는 src/app.ts에 작성한다. 기본 express 구조를 보면 본 사람들은 알겠지만 app의 크기가 조금 크다. 그래서 이를 조금 줄이고 싶다. app에서 하는 일은 사실 간단하다. 기본 설정을 불러오고 app.use를 통해 라우터에 연결해주는 것이다. 그리고 listen으로 서버를 실행시키면 끝이다. 이를 바탕으로 코드를 작성해보면 아래와 같다. 우리가 엔트리포인트에서 몰라도 되는 코드는 loaders를 이용해 불러오니 깔끔해졌다.

import express, { Application } from 'express';
import errorMiddleware from 'middlewares/errorMiddleware';
import jwtMiddleware from 'middlewares/jwtMiddleware';
import loaders from './loaders';
import router from './routes';

const startServer = async () => {
  const app: Application = express();
  await loaders(app);

  const port = process.env.PORT;

  app.use('/api', jwtMiddleware, router);

  app.use(errorMiddleware);

  app.listen(port, () => {
    console.log(`Server running on ${port}`);
  });
};

startServer();

loaders

위에서 말한 로더를 생각해보자. 그냥 기존에 사용하던 코드를 로더에 넣으면 끝이다. 물론 기능에 따른 분리가 필요하다. 나같은경우에는 index에서 db, express, cors 설정을 해줬다.

config

로더에서 기본 설정을 불러오는 경우가 있다. cors라던가 env가 그렇다. 이런 파일들은 config 폴더에서 관리하자

routes

app에서 결국 라우터에 연결해준다. 사실 라우터에서 데이터를 프론트와 db사이를 주고 받으면 끝이다. 그런데 라우터가 너무 방대해진다. 일단 차근차근 생각을 해보자. 클라이언트 => express => db => express => 클라이언트 대충 이런 구조다. 우리가 할 수 있는 것은 express 내에서 구조를 나누는 것이다. 여기서 디자인 패턴에 대한 이해가 필요하다. 보통 자바에서 많이 사용한다고 하는데 사실 나는 자바를 잘 몰라서 정확히는 모른다. 어쨋든 mvc 패턴, dao, dto, entity 같은 개념이 필요하다.

dao, dto, entity란

dao는 data access objct이다. 즉 데이터에 접근하는 객체이다.
dto는 data transfer object이다. 즉 계층간 데이터 교환이 이루어 질 수 있도록 하는 객체이다.
entity는 개체, 실체라는 뜻이고 보통 데이터베이스를 설계할 때 문장에서 명사에 해당되고 관계와 속성을 가진다. entity에서 관계를 추가하면 데이터 모델이 된다고 한다. 설명이 조금 부족한 것같다. 이런데서도 cs의 중요성이 들어난다.

mvc 패턴이란

mvc는 model, view, controller를 뜻한다. model은 데이터를 나타내고 view는 데이터를 시각적으로 나타낸다. 서버라면 model에서 db에 직접 접근하고 view는 비지니스 로직을 처리하며 그 데이터를 가공하는 역할이다. 여기서 중요한 개념이 무엇일까?? 의존성을 줄이는 것이 중요하다. 계층을 나누는데 서로 복잡하게 연관되어 있다면 그것은 제대로 계층을 나눈것이 아니다. model에서는 데이터만 가지고 있고 view에서는 데이터에 접근하면 안된다. 프로젝트를 진행하다보면 이를 깨트리는 경우가 있다. (내 얘기..) mv면 사실 끝이다. 그래서 mv패턴도 있고 mv로 시작하는 굉장히 많은 디자인 패턴이 있다. 여기서 controller가 왜 필요할까?? 다시 클라이언트에서 서버에 접근하는 경우를 생각해보자. 클라이언트에서 가장 많이 사용하는 http 메서드는 get, post다. post를 생각하면 data를 서버로 보내고 결과값을 다시 전달받는다. 이 경우에 불필요한 req,res를 서비스 계층이 필요할까?? 하는 생각이 든다. 필요한 정보만 넘겨주면 될 것 같다. 그리고 http response도 서비스 계층에서 할 필요가 없다. 또한 라우터 하나에서 여러가지 서비스 계층에 접근할 수도 있다. 이런 여러가지 이유때문에 컨트롤러가 필요하다. 사실 기본적으로 컨트롤러가 필요한 이유는 모델과 뷰의 의존성을 낮추고 느슨하게 연결하는 것이다. 컨트롤러에서 서비스 계층을 거치지 않고 바로 모델에 접근할 수도 있다. 하지만 코딩할 때 일관성을 무시할 수 없다. 서비스 계층이 필요없는 경우 바로 컨트롤러에서 모델에 연결해주면 될까? 이건 상황에 따라 판단하자. 나는 지금 일관성을 지키려고 하고 있다. 사실 모델에서 원하지 않는 값을 얻는 경우도 있어서 서비스 계층에서 에러를 처리해주기도 한다. 그래서 거의 모든 경우에 서비스 계층이 필요하다. mvc 패턴은 자바뿐만 아니라 프로트에서도 많이 사용되는 디자인 패턴이다. mvc패턴을 직접적으로 사용하지 않더라도 이런 개념을 아는 것은 굉장히 중요하다.

entity

entity라는 폴더에 db의 정보를 입력하자. 보통 orm을 사용하기 때문에 사용하는 orm 설정을 해주면 된다.

controllers, services, repositories

위에서 설명한 개념으로 폴더를 만들자. 흐름을 말하자면 routes => controllers => services => repositories 이다.

middlewares

express에서는 미들웨어라는 개념이 굉장히 중요하다. next함수로 넘겨주는 것인데 자세한 내용은 공식문서를 참조하자. 간단한 미들웨어를 설명하자면 error, jwt, 로그인 상태 확인 등이 있다.

https://expressjs.com/ko/guide/using-middleware.html

validation

validation은 굉장히 중요하다. 잘못하면 원하지 않는 데이터가 들어오게 되고 오류를 일으킨다. 프론트, 서버 두군데서 이중으로 검증을 반드시 해야한다. controller로 넘기기 전에 validation 폴더에서 검증을 하자. 이메일 length, 글 제목 length 무엇이든지 말이다. 나는 joi라는 라이브러리를 사용한다. 어떤식으로든 검증을 하자.

constants

중복되는 상수가 필요할 때가 있다. 그럴때는 이 폴더에서 관리하자

customTypes

typescript의 경우에만 필요하다. typescript에서는 변수를 따로 추가해줘야되는 경우가 있다. 가장 대표적인 경우가 req.user이다. 다들 한번쯤은 req.user 지옥을 경험해봤을 것이다... 필요한 타입을 추가해주자.

types

이것도 typescript의 경우에 해당된다. 필요한 공통 타입 또는 인터페이스를 만들어주자

error

validation 만큼이나 에러처리도 중요하다. 에러처리하는 방법은 다양하다. 어떻게든 처리를 해줘야한다. 내가 에러처리를 하는 방법을 소개하겠다. 좋은 방법인지는 모르겠으니 참고만...

custom-error.ts

/**
 * custom error
 * @param {number} status - http status code
 * @param {string} message - error message for developer
 * @param {string} from - error from where
 * @param {strinig} customMessage - custom error message for joi and so on
 * @return {CustomError} - custom error object
 */

class CustomError extends Error {
  status: number;

  from: string;

  customMessage?: string;

  constructor(status: number, message: string, from: string, customMessage?: string) {
    super(message);

    if (Error.captureStackTrace) {
      Error.captureStackTrace(this, CustomError);
    }

    this.status = status;
    this.from = from;
    this.customMessage = customMessage;
    Object.setPrototypeOf(this, CustomError.prototype);
  }
}

export default CustomError;

일단 커스텀 에러 클래스를 만들자. 주석으로 설명을 해놨지만 조금 더 상세하게 설명하겠다.
status는 status code이다. 사실 message만 받고 마지막에 status를 처리해줘도 되는데 나는 status code를 에러를 발생시킬 때 적어주는 것이 더 편했다. 다시 말하지만 정답이 뭔진 모르겠고 나 나름대로 생각했을 뿐이다.
message는 개발자를 위한 에러이다. 코딩할때 로그는 개발자를 위한 용어가 필요하고 클라이언트단에서 유저가 이해하기 위한 메세지는 조금 다르다. 그래서 나중에 따로 처리를 해준다. 조금 뒤에 설명...
from은 어디서 발생했는지 알려준다. 사실 필요없을 수도 있는데 나누지 않으면 에러처리를 하는 함수가 커지게 된다. 이를 방지하고자 from을 추가했다.
customMessage는 사실 joi때문에 추가했다. 보통은 message를 보고 리턴하는 메세지를 변환하는데 joi에서는 어떤 검증을 실패하느냐에 따라서 메세지가 달라지게 된다. 사실 customMessage를 추가하지 않아도 구현할 수 있다. 하지만 나는 이 방법이 더 좋다고 생각했다. 예시를 보여주는게 더 편할 것 같아서 예시를 적어두겠다.

ex) login-validation.ts

import { Request, Response, NextFunction } from 'express';
import Joi from 'joi';
import errorGenerator from 'error/error-generator';
import CustomError from 'error/custom-error';
import errorProcess from 'error/error-process';
import { JOI_ERROR_MESSAGE } from 'constants/error-message';

const FROM = 'joi';

const loginValidation = (req: Request, res: Response, next: NextFunction): void => {
  try {
    const schema = Joi.object({
      email: Joi.string().required().empty('').messages({
        'any.required': `이메일을 입력해주세요`,
      }),
      password: Joi.string().required().empty('').messages({
        'any.required': `비밀번호를 입력해주세요`,
      }),
    });

    const validationResult = schema.validate(req.body);
    const { error } = validationResult;

    if (error) {
      throw errorGenerator({
        status: 400,
        message: JOI_ERROR_MESSAGE.invalidRequestBody,
        customMessage: error.message,
        from: FROM,
      });
    }

    next();
  } catch (err) {
    errorProcess(res, err as CustomError);
  }
};

export default loginValidation;

이제 내부를 살펴보자. 기본적으로는 Error를 상속한다.
captureStackTrace는 위의 에러 내용을 숨긴다. 자세한 내용은 https://ui.toast.com/weekly-pick/ko_20170306 를 참조하자
Object.setPrototypeOf(this, CustomError.prototype);는 조금 뜬금없는데 프로토타입을 재설정해주지 않으면 아래서 이용하는 instanceof가 제대로 되지 않아서 추가해줬다.

error-generator.ts

위에서 만든 custom error를 생성하는 함수다.

import CustomError from './custom-error';

interface ParamType {
  status: number;
  message: string;
  from: string;
  customMessage?: string;
}

const errorGenerator = ({ status, message, from, customMessage }: ParamType): CustomError => {
  return new CustomError(status, message, from, customMessage);
};

export default errorGenerator;

error-process.ts

import { Response } from 'express';
import CustomError from './custom-error';
import errorAuth from './error-handler/error-auth';
import errorComment from './error-handler/error-comment';
import errorJoi from './error-handler/error-joi';
import errorJwt from './error-handler/error-jwt';
import errorPost from './error-handler/error-post';

const errorHandler = (err: CustomError) => {
  switch (err.from) {
    case 'jwt':
      return errorJwt(err);
    case 'joi':
      return errorJoi(err);
    case 'auth':
      return errorAuth(err);
    case 'post':
      return errorPost(err);
    case 'comment':
      return errorComment(err);
    default:
      return { status: 500, errorMessage: '에러 핸들러가 존재하지 않습니다' };
  }
};

const errorProcess = (res: Response, err: CustomError | Error) => {
  console.log(err);
  if (err instanceof CustomError) {
    const { status, errorMessage } = errorHandler(err);
    res.status(status).json({ errorMessage });
  } else {
    res.status(500).json({ errorMessage: '서버 에러' });
  }
};

export default errorProcess;

errorGenerator로 에러를 발생시켜서 throw하고 catch문에서 errorProcess 함수를 호출하면 된다.

error-handler

에러 핸들러에서는 커스텀에러의 message를 이용자를 위한 메세지로 변경시켜주는 역할을 한다.

error의 흐름

일단 엔트리포인트인 app을 통해서 라우터에 전달되는 것은 공통이다. 라우터에서 미들웨어를 통해(login상태 확인, validation 등등) 컨트롤러에 전달한다. 먼저 컨트롤러에 가기 전에 에러가 발생하는 경우가 잇다. 그럴때는 try catch문으로 감싸고 위에서 말한것처럼 throw errorGenerator를 하고 catch문에서 errorProcess 함수를 호출하면 된다.
이제 컨트롤러로 넘어간 경우를 생각해보자. 일단 try catch문으로 감싸고 catch문에서 throw err를 해준다. 컨트롤러에서는 그저 에러를 넘겨줄 뿐이다. 그리고 에러는 서비스 계층에서 발생시킨다. 여기서 throw를 하면 컨트롤러의 catch문에서 잡히게 된다. 그리고 넘긴 에러는 엔트리 포인트에서 아래와 같은 방법으로 넘겨주는 것이다.

  app.use('/api', jwtMiddleware, router);

  app.use(errorMiddleware);

error-middleware.ts

import CustomError from 'error/custom-error';
import errorProcess from 'error/error-process';
import { NextFunction, Request, Response } from 'express';

const errorMiddleware = (err: CustomError | Error, req: Request, res: Response, next: NextFunction) => {
  // TODO: 필요하다면 나중에 next로 미들웨어 하나 더 구현 ex) mysql, mongodb
  errorProcess(res, err);
  next();
};

export default errorMiddleware;

여기서 경우에 따라 한번 더 넘겨주는 경우가 있다. 간단한 토이프로젝트라면 아마 필요없을 것이다.

utils

유틸함수를 여기서 정의해주자.

env

env에는 노출되면 안되는 정보가 들어간다. test, dev, prod 경우에 따라 여러개의 파일으로 관리하자.

전체 구조

server
├── src
│ ├─config
│ ├─constants
│ ├─controllers
│ ├─customTypes
│ ├─entity
│ ├─error
│ ├─loaders
│ ├─middlewares
│ ├─repositories
│ ├─routes
│ ├─services
│ ├─types
│ ├─utils
│ ├─validation
│ └─app.ts
├─.dev.env
├─.env
├─.eslintrc.json
├─.mock.env
├─.prettierrc
├─package.json
├─tsconfig.json
└─yarn.lock

추가로 신경써야 될 것

의존성 주입이라는 부분을 신경을 못썼다. 좀 더 공부해보자.

추가로 테스트와 로깅은 필수다. 여기에 작성하지는 않았지만 꼭 신경써야한다. 다른 글에서 올리겠다.

728x90
LIST
댓글
공지사항