// 에드센스


 

분산락이란?

여러 서버에서 공유된 데이터를 제어하기 위해 사용하는 기술

 

 

이 이미지처럼 하나의 자원(상품 재고)에 여러 서버에서 동시에 접근을 한다면 데이터 정합성이 깨질 수 있다.

이때 레디스를 사용해서 잠금(락)을 걸어주어 한번에 하나의 서버에서만 자원에 접근하도록 제한하는것이 분산락이다.

 

 

 

 

 

 

주문서버와 정산서버가 거의 동시에 레디스에 Lock을 시도했지만 주문서버가 성공했고, 주문서버가 상품재고에 요청을 보낼 수 있는 자격이 생긴것이다.

 

 

 

 

 

적용 상황

다음 그림과 같이 멀티 인스턴스 환경에서 각 인스턴스마다 DB에 어떤 트랜잭션을 날리는 스케줄러가 동작하고있을 때

READ라면 상관없겠지만 DB의 상태를 변경시키는 트랜잭션을 동시다발적으로 날리면 우리가 예상한 결과와 달라질 것이다.

 

 

 

 


이 코드처럼 10초마다 디비의 count를 1씩 증가시켜주는 스케줄러가 있다고 해보자.

 

 

 

pm2를 사용하여 로컬에서 10개의 인스턴스를 실행시켜보면

 

 

 

 

이렇게 스케줄러가 한 번 실행될때마다 10개의 인스턴스에서 각각 count를 1씩 더하는 쿼리가 발생하게되고 결과적으로 한번에 10씩 count가 더해진다

 

 

 

 

 

70초 경과하면 70이 된다

 

 

 

 

 

 


적용하기

 

레디스를 추가하여 Lock을 획득한 인스턴스만 쿼리를 발생시키도록 제한해보자

 

 

 

레디스로 분산락은 이렇게 구현하였다.

setLock은 레디스 저장소에 key-value 하나를 추가해주는 것이고 이 동작이 10개의 인스턴스에서 동시에 실행되겠지만 그 중에서 가장 빠른 인스턴스만 성공할 것이고 나머지 9개의 인스턴스는 락에 실패할 것이다. (이미 존재하는 key이기에)

 

 

 

 

모종의 이유로 delLock()이 동작하지 않을 수 있기에 EX 설정을 하여 자동으로 expire 되도록 내부에 설정해주었다.

NX설정은 이미 key가 존재하면 생성을 막도록 하는 설정이다.

 

 

 

 

 

아무튼 10개의 인스턴스에서 락에 성공한 인스턴스만 쿼리를 실행하는 것을 볼 수 있다.

 

nodejs로 개발을 하던 중 데이터베이스 테이블에 컬럼을 추가할 일이 있었다.

그때 사용한 방법을 기록해볼까 한다.


1. 데이터베이스 마이그레이션?

 

 

어디서 많이 들어보긴했지만 정확한 의미는 잘 몰랐다.

우선 마이그레이션이란 큰 의미로

"한 운영환경으로부터 다른 운영환경으로 옮기는 작업"

 

이라고 할 수 있다. Migration을 사전에 검색하면 나오는 '이주, 이송'의 개념이다.

하드웨어, 소프트웨어, 네트워크 등 넓은 범위에서 마이그레이션의 개념이 사용되고 있다. 

 

 

데이터베이스에서 한 예시로,

개발환경에서 스키마와 운영환경에서 스키마에 차이가 있을때 마이그레이션을 진행한다고 할 수 있다.

작게는 테이블 생성과 수정부터 하나의 애플리케이션 혹은 전체 시스템을 옮기는 것까지를 마이그레이션이라고 한다.

나는 데이터베이스에서 한 테이블에 컬럼을 추가하고자 한다.

 

 

 

 


2. 실습

현재 Nodejs에서 Sequelize 라이브러리로 데이터베이스를 조작하고 있다.

개발단계에서는 sync({force: true})를 통해서 컬럼을 강제로 추가할 순 있겠지만 운영단계에서는 불가능하다.

따라서 Sequelize에서 지원하는 마이그레이션으로 컬럼을 추가해볼까 한다.

 

 

0. Sequelize CLI 설치

# using npm
npm install --save-dev sequelize-cli
# using yarn
yarn add sequelize-cli --dev

 

 

 

1. init 명령

# using npm
npx sequelize-cli init
# using yarn
yarn sequelize-cli init

init 명령을 실행하면

  • config, 데이터베이스에 연결하는 방법을 CLI에 알려주는 구성 파일이 포함돼 있다.
  • models, 프로젝트의 모든 모델을 포함한다.
  • migrations, 모든 마이그레이션 파일 포함한다.
  • seeders, 모든 시드 파일을 포함한다.

이 4개의 폴더가 생성된다.

 

 

 

2. 데이터베이스 설정

config/config.json 파일에서 각 환경별로 나의 데이터베이스 정보를 입력해두어야 한다.

{
  "development": {
    "username": "mydb",
    "password": "mydb-password",
    "database": "postgres",
    "host": "mydb-dev.1234.ap-northeast-2.rds.amazonaws.com",
    "post": 5432,
    "dialect": "postgres"
  },
  "test": {
    "username": "root",
    "password": null,
    "database": "database_test",
    "host": "127.0.0.1",
    "dialect": "mysql"
  },
  "production": {
    "username": "root",
    "password": null,
    "database": "database_production",
    "host": "127.0.0.1",
    "dialect": "mysql"
  }
}

개발환경만 설정했다.

Sequelize CLI는 기본값이 mysql로 설정돼있다. 다른 데이터베이스를 사용하는 경우 "dialect"옵션의 내용을 수정해줘야한다.

또 Sequelize는 각 데이터베이스에 대한 포트로 기본값들을 사용한다.(postgres의 경우 5432포트)

다른 포트를 지정해야 하는 경우 "port"필드를 추가하여 설정할 수 있다.

 

 

 

3. 마이그레이션 파일 생성

sequelize migration:create --name test-migration

이 명령어로 마이그레이션 설정 파일을 생성한다. --name 옵션으로 파일의 이름을 지정한다.

그러면 migration 폴더에 20220318184913-test-migration.js 이라는 파일이 생성될 것이다.(날짜-이름.js)

 

 

이렇게 생긴 파일이다.

"use strict"

module.exports = {
  up: function (queryInterface, Sequelize) {
    /*
      Add altering commands here.
      Return a promise to correctly handle asynchronicity.

      Example:
      return queryInterface.createTable('users', { id: Sequelize.INTEGER });
    */
  },

  down: function (queryInterface, Sequelize) {
    /*
      Add reverting commands here.
      Return a promise to correctly handle asynchronicity.

      Example:
      return queryInterface.dropTable('users');
    */
  },
}

up 메소드에는 마이그레이션 할 내용을 기재하고 

down 메소드에는 롤백할 내용을 기재한다.

 

 

컬럼을 추가하는 up메서드를 작성해보자.

'use strict';

module.exports = {
  up: async (queryInterface, Sequelize) => {
    return queryInterface.addColumn('product', 'url', {
      type: Sequelize.STRING,
      allowNull: false,
      defaultValue: 'https://myProduct.s3.ap-northeast-2.amazonaws.com/default.png',
    });
  },

  down: async (queryInterface, Sequelize) => {
    return queryInterface.removeColumn("product", "url")
  },
};

up 메소드에는 product테이블에 상품의 사진을 저장하는 url 컬럼을 추가한다.

down 메소드에는 up 메소드에 대한 롤백 내용을 기재해보았다.

 

 

 

4. 마이그레이션 진행

sequelize db:migrate --env development

를 통해서 up 메소드에 기재한 마이그레이션을 실행할 수 있다.

--env는 default로 development로 설정돼있긴하다.

 

 

 

5. 롤백

롤백은 이렇게 해주면 된다.

sequelize db:migrate:undo --env development

 

 

 

6. 다중 마이그레이션 

해보진 않았지만 잘 정리된 블로그의 내용을 참고했다.

 

만약 컬럼을 여러개 추가할 때는 어떻게 해야할까.

up() 함수는 프라미스를 리턴하게 되어있는데 promise로 구성된 배열을 반환해도 된다.

컬럼을 여러개 추가할 것이라면 addColumn()을 배열에 담아 리턴하면 된다.

물론 롤백할 때도 동일하게 removeColumn()를 배열에 담아서 반환한다. 

module.exports = {
  up: function (queryInterface, Sequelize) {
    return [
      queryInterface.addColumn("product", "url", {
        type: Sequelize.STRING,
      }),
      queryInterface.addColumn("product", "price", {
        type: Sequelize.STRING,
      }),
    ]
  },

  down: function (queryInterface, Sequelize) {
    return [
      queryInterface.removeColumn("product", "url"),
      queryInterface.removeColumn("product", "price"),
    ]
  },
}

물론 직접 raw 쿼리를 사용할 수도 있다.

module.exports = {
  up: function (queryInterface, Sequelize) {
    var sql = "ALTER TABLE product ADD COLUMN url varchar(255) NOT NULL"

    return queryInterface.sequelize.query(sql, {
      type: Sequelize.QueryTypes.RAW,
    })
  },

  down: function (queryInterface, Sequelize) {
    var sql = "ALTER TABLE product DROP COLUMN url"

    return queryInterface.sequelize.query(sql, {
      type: Sequelize.QueryTypes.RAW,
    })
  },
}

 

 

 

 

 

 

 

참고:

더보기

또또 너무 오랜만에 글을 쓴다. 퇴근하면 왤케 피곤한걸까.

기존에 사용하던 stoplight에서 swagger로 전환하는 작업을 했다.

왜? 코드 안에서 바로 수정할 수 있기에 더 간편해서.

근데 한땀한땀 핸드메이드로 스웨거 주석을 작성하다가 스웨거 자동생성 라이브러리를 발견했고 그것을 사용해 보았다.


1. swagger란?

  • Open Api Specification(OAS)를 위한 프레임워크이다.
  • API들이 가지고 있는 스펙(spec)을 명세, 관리할 수 있는 프로젝트/문서
  • API 사용 방법을 사용자에게 알려주는 문서
  • express에서는 주석형태의 yaml형식으로 swagger 문서를 정의할 수 있다.
  • URL에 /api-docs으로(내가 지정한) 접근하면 swagger가 만들어주는 페이지에 접근할 수 있다.

 

 

 


2. swagger 자동 생성 라이브러리 swagger-autogen

https://github.com/davibaltar/swagger-autogen

 

GitHub - davibaltar/swagger-autogen: This module performs the automatic construction of the Swagger documentation. The module ca

This module performs the automatic construction of the Swagger documentation. The module can identify the endpoints and automatically capture methods such as to get, post, put, and so on. The modul...

github.com

 

일반적인 express swagger 적용은 이미 다른 블로그에도 친절하게 잘 적혀있다.

하지만 api가 하나 추가될때마다 한땀 한땀 주석을 작성(혹은 복붙)하기엔 너무 귀찮을 것 같았다.

 

그래서 찾아본 결과 router가 위치한 파일의 경로를 알려주면 자동으로 해당 router를 인식하고,

그 밑에 딸린 router들도 인식하여 api 문서를 위한 json파일을 자동 생성해주는 라이브러리를 찾았다.

 

 

npm start를 한 후의 동작을 보자면

  1. package.json의 prestart 스크립트를 통해서 swagger.js를 실행한다. 이 파일은 지정된 경로에 존재하는 파일들에서 라우터와 swagger 주석들을 읽는다.
  2. swagger.js의 실행 결과로 swagger-output.json 파일이 생성된다. 이 파일은 우리가 생성할 swagger 문서의 구조를 나타낸다.
  3. 생성된 json을 바탕으로 swagger 문서가 생성된다.

 

주의사항.

swagger-output.json은 prestart 스크립트에 의해서 생성되기에 nodemon처럼 저장후 자동으로 서버가 재실행 되는 경우 prestart 스크립트가 실행 안될 수 있다.

즉, 서버를 완전히 종료하고 다시 npm start를 해줘야 prestart가 실행되며 swagger-output.json 파일이 생성(갱신)된다.

 


3. 적용하기

0.  npm install

npm i swagger-ui-express swagger-autogen

 

 

1.  프로젝트 구조

├ src

└─ swagger

      └─ swagger.js

      └─ swagger-output.json

└─ loader

      └─ express.ts

 

swagger.js을 prestart 스크립트로 실행하여 swagger-output.json을 얻어내고,

express.ts에서 swagger-output.json을 참조하여 swagger 문서를 생성하는 함수를 호출한다.

 

 

2.  코드

swagger.js

const swaggerAutogen = require('swagger-autogen')({ openapi: '3.0.0' });

const options = {
  info: {
    title: 'This is my API Document',
    description: '이렇게 스웨거 자동생성이 됩니다.',
  },
  servers: [
    {
      url: 'http://localhost:3000',
    },
  ],
  schemes: ['http'],
  securityDefinitions: {
    bearerAuth: {
      type: 'http',
      scheme: 'bearer',
      in: 'header',
      bearerFormat: 'JWT',
    },
  },
};
const outputFile = './src/swagger/swagger-output.json';
const endpointsFiles = ['./src/loaders/express.ts'];
swaggerAutogen(outputFile, endpointsFiles, options);

securityDefinitions 부분은 JWT를 위한 설정이다.

저 속성을 지정하면 authorize라는 버튼이 생기며 JWT Access Token을 header에 등록할 수 있다.

여러 api들을 요청하고 응답을 확인하기위해 일일이 Access Token을 넣어줄 필요가 없다.

 

보다시피 outputFile의 경로를 지정해 주었고 swagger middleware를 설정할 때 이곳을 참조하면 된다.

endpointsFiles는 router가 위치한 파일이다. 나의 경우 이곳에 최상단 router가 있기에 이곳을 지정했다.

 

├ /api

└─ /user

      └─ /

      └─ /marketing

      └─ /phone

└─ /board

      └─ /

      └─ /admin

└─ /ping

 

이와 같은 router구조를 가진다고 했을 때, /api가 위치한 곳을 가리켜야 swagger가 만들어졌을때 

/api/user/phone

이렇게 전체 경로가 표시된다.

 

 

아무튼 이렇게 생성된 json파일을 가지고 swagger를 생성해준다.

import swaggerFile from '../swagger/swagger-output.json';
import swaggerUi from 'swagger-ui-express';

//Swagger
app.use('/api-docs', swaggerUi.serve, swaggerUi.setup(swaggerFile, { explorer: true }));

 

 

이제 /api-docs로 접속하면 다음과 같은 화면을 볼 수 있다.

 

 

근데 지금은 모든 api들이 무질서하게 나열돼 있어서 보기 안좋다.

tag를 걸어보자.

tag를 걸면서 request, response 양식도 정해주자.

 

 

다음과 같은 주석을 각 api 함수 내부에 넣어주자. 이게 좀 가독성을 해칠 수 있겠지만 그나마 가장 간단하고 편리한 방법이라고 생각한다...

다음 api는 user의 phone을 수정하는 put api이다.

  /*
   #swagger.tags = ['Users']
   #swagger.summary = '유저 phone 수정'
   #swagger.description = '유저 phone을 업데이트한다.'
   #swagger.security = [{
       "bearerAuth": []
   }]
   #swagger.requestBody = {
        required: true,
        content: {
            "application/json": {
                schema: {
                  "type": "object",
                    "properties": {
                      "phone": {
                        "type": "string",
                        "required": false
                      }
                    }
                },
                example: {
                    "phone": "010-1111-2222",
                },
            },
        }
    }
    #swagger.responses[200] = {
        description: "유저 phone 수정 완료",
        content: {
            "application/json": {
                schema: {
                    "data": {
                        "phone": "010-0000-0000",
                    }
                },
                example: {
                    "data": {
                        "phone": "010-1111-2222",
                    }
                },
            }
        }
    }
*/

 

 

태그를 Users로 지정한 것들은 다음과 같이 이쁘게 그룹화된다.

 

 

또 요청/응답 형식의 예시를 지정해 둘 수 있다.

이렇게 하면 다른 개발자들과 협업하기 편리할 것이다.

나의 경우는 api 함수 내부 가독성을 최대한 살리기위해 200 응답만 예시를 정해두었고 나머지는 생략했다.

 

추가적인 속성들은 위에 첨부한 swagger-autogen 공식 깃허브 문서를 참고하면 쉽게 따라할 수 있을 것이다.

 

 

 

 


4. 마치며

나는 응애개발자라 아직 주석이 가득한 코드가 익숙하지 않다.

자바의 경우 어노테이션으로 깔끔 세련되게 됐던거같은데 노드는 좀 애매한 것 같다..

처음부터 swagger 자동 생성을 찾아볼걸, 괜히 한땀 한땀 주석만 쓰고있느라 시간낭비 한 것 같다. 큿소!

express 앱을 개발하며 자스/타스에 대해 하나씩 배워가고 있다.

오늘은 기본값 파라미터를 사용해보았다.


1. 내용

파라미터에 기본값 파라미터를 설정하면 값이 없거나 undefined가 전달될 경우 지정한 기본값으로 사용한다.

 

 

예시)

function multiply(a, b = 1) {
  return a * b;
}

console.log(multiply(5, 2));
// expected output: 10

console.log(multiply(5));
// expected output: 5

 

 

같은 개념으로 함수를 파라미터로 사용할 때도 기본값을 지정할 수 있다.

function callSomething(thing = something()) {
  return thing
}

let numberOfTimesCalled = 0
function something(){
  numberOfTimesCalled += 1
  return numberOfTimesCalled
}

callSomething()  // 1
callSomething()  // 2

 

 

앞의 파라미터를 뒷쪽의 파라미터의 기본값으로 사용할 수 있다.

function greet(name, greeting, message = greeting + ' ' + name) {
  return [name, greeting, message]
}

greet('David', 'Hi')                      // ["David", "Hi", "HiDavid"]
greet('David', 'Hi', 'Happy Birthday!')   // ["David", "Hi", "Happy Birthday!"]

 

 

 

 


2. 실제로 사용한 상황

express앱을 개발하며 user의 컨트롤러-서비스 부분을 개발하고 있었다.

서비스에서 sequelize ORM을 사용하고 있기에 쿼리문을 간단하게 생성하여 DB를 조작한다.

 

로직에서 service를 호출하는 컨트롤러에 따라 다르게 조회할 컬럼을 설정해야 했었다.

이때 기본값 파라미터를 설정해주는 것만으로 아주 간단하게 문제를 해결할 수 있었다.

 

export const findOneUserInfo = async (
  uid: string,
  attributes: string[] = ['name', 'uid', 'email', 'phone', 'createdAt'],
): Promise<any> => {
  try {
    const result = await User.findOne({
      attributes: attributes,
      where: {
        uid: uid,
      },
    });
    return result;
  } catch (err) {
    return err;
  }
};

코드를 보면 인자로 uid와 attributes를 받는다. 원래는 attributes가 없었는데 기본값 파라미터로 추가해주었다.

 

findOneUserInfo(uid)처럼 uid만 사용하여 호출했을때는 조회할 컬럼이 기본값 파라미터의 

['name', 'uid', 'email', 'phone', 'createdAt']로 지정이 되어 쿼리가 수행된다.

 

하지만 이 컬럼들 이외로 알고싶다면 

findOneUserInfo(uid, ['address', 'gender']) 이런식으로 인자를 넣어주면 된다.

이러면 기본값 파라미터 값에 ['address', 'gender']가 추가된 것이 아닌 ['address', 'gender']만 조회된다. 주의.

 

 

 

 

참고:

nodejs를 통해 애플리케이션을 개발하며 테스트를 위해 Jest 프레임워크를 사용했다.

그런데 테스트 코드 작성이 처음이기도 해서 그런지 "mock"이라는 개념에서 많이 헤맸다.

첫 테스트 코드 작성이기에 다른 블로그의 글 보다 질 좋은 정보는 없겠지만 내가 어떻게 mock을 활용하여 단위/통합 테스트를 진행했는지 나중의 나를 위해 기록해놓는다.


1. Jest란

페이스북에서 개발한 JS를 위한 테스트 프레임워크이다.

npm install --save-dev jest

이렇게 설치해 주고 사용하자. --save-dev의 의미를 모르겠다면 마침 엊그제 쓴 좋은 글이있다. ㅎㅎ

https://llshl.tistory.com/40?category=961696 

 

[NodeJS] npm install option

노드자스를 통해 개발하면서 무지성 install들을 해온것 같다. 그래서 정리해보는 시간. 1. npm options 기본적으로 npm install은 ./node_modules 폴더에 패키지를 다운받는 명령어이다. 나는 주로 npm 쓸때

llshl.tistory.com

 

 

설치를 마치고 package.json의 script에 

"scripts": {
	...
    "test": "jest",
    ...
  },

이렇게 한 줄 넣어주자. 이제 우린 npm test라는 명령을 통해 테스트를 할 수 있게 됐다.

 

 

 

 

2. mock이란

기본적인 테스트 방법은 다른 블로그들이 아주 자세히 설명하고 있으니 여기서는 굳이 기록하지 않을려 한다.

 

"목", "목하다", "모킹하다"라고 표현하는 mock이란 간단히 말해서 "가짜로 대체하기" 기능이라고 말할 수 있다.

왜 가짜로 대체하는가?

 

  • 테스트 하고싶은 기능이 다른 기능들과 엮여있을 경우(의존) 정확한 테스트를 하기 힘들기 때문

 

예를들어 request body에 사용자의 id와 password를 넣어서 post요청을 보내면 컨트롤러에서 정보를 추출한 후 데이터베이스에 넣어주는 단위테스트를 하고 싶다고 하자.

데이터베이스에 저장 요청을 보내면 성공이든 실패든 응답이 반환될 것이고 반환된 응답을 기준으로 테스트의 성공과 실패를 구분한다.

 

 

 

 

이때 실제 데이터베이스에 사용자의 id, password를 넣는 방식으로 테스트를 하는 것은 좋은 방법이 아니다.

실제 트랜잭션이 일어나기에 IO 시간도 테스트에 포함되고, 데이터베이스 연결 상태에 따라 테스트가 실패할 수도 있기 때문이다.

테스트가 실패했을 경우 내가 작성한 컨트롤러 코드의 문제인지, 데이터베이스의 문제인지 알아차리기도 힘들기 때문에 올바른 단위테스트라고 할 수 없다.

 

따라서 실제 데이터베이스에 데이터를 넣는 것이 아니라 넣은 셈 치자는 개념이다.

데이터베이스가 잘 작동하는지는 데이터베이스 관련 테스트에서 확인하면 되고 우리는 지금 controller에 대한 테스트를 진행하고 있으니 데이터베이스가 잘 작동한다는 전제를 깔고 가자는 뜻이다. 

 

데이터베이스 mocking을 표현하면 다음 그림과 같다.

 

 

 

기존의 데이터베이스 저장 메소드를 mock 함수로 만든다. mock 함수가 되면 그 함수는 아무런 기능이 없어진다. mock 함수를 호출하면 

undefined를 반환한다. 그냥 껍데기만 남기고 속을 싹 비워낸 그런 느낌

 

이제 이 mock함수를 호출했을때 반환 받기 원하는 값을 우리가 직접 지정해 준다.

본래 데이터 저장 함수이기에 데이터 저장 성공 후 응답 값이 다음과 같다고 가정해보자.

{
	"result": "success",
	"id": "testUser@test.com"
}

이런 결과와 저장된 id를 json으로 응답 받았었다면 mock함수가 반환하길 원하는 값으로 저 json을 지정해주자.

우리는 controller의 로직에 집중해야하니 데이터베이스는 "대충 이런이런 값을 반환한다고 치자"라고 하고 넘어가는 개념이다.

 

 

 

 

3. 작성해본 테스트 코드

모킹을 하는 방법은 Jest에서 제공하는 fn(), spyOn(), mock() 함수를 사용하면 된다. 이 중에서 난 fn()을 쓰려고 했지만 원인 모를 에러로 인해 spyOn 함수를 사용했다! 

이 3가지 함수에 대한 설명은 이 블로그에서 친절하게 설명을 해두었다.

 

 

UserController에 있는 getPassword함수를 테스트해보자.

getPassword 함수는 다음과 같다.

export const getPassword = async (req: Request, res: Response, next: NextFunction) => {
  try {
    const id: string = req.query.id ? req.query.id : req['claims']['email'];
    return res.status(200).json(await account.findPasswordById(id));
  } catch (e) {
    Logger.error('%o', e);
    return next(e);
  }
};

getPassword함수는 사용자 Id를 받아서 db에서 password를 조회하는 함수다.

이 함수의 기능은 두가지다.

  1. req로부터 사용자 id를 추출하고,
  2. findPasswordById함수에 사용자 id를 넘겨준다.

http 요청을 만들기 위해 httpMocks라는 모듈을 사용하였다.

 

 

 

작성한 테스트코드는 다음과 같다.

import * as model from '../models/student.model';

test('[DB 데이터 조회 테스트] 사용자 id를 통해 password 조회 테스트', async () => {
  const req = httpMocks.createRequest({
    method: 'GET',
    url: '/api/test?id=user@test.com',
  });
  const res = httpMocks.createResponse();
  const next = null;
  const expectedResult = { password: 'mypass123' };
  const mockFindPasswordById = jest.spyOn(model, 'findPasswordById');
  mockFindPasswordById.mockResolvedValue(expectedResult);

  await UserController.getPassword(req, res, next);

  expect(res.statusCode).toBe(200);
  expect(res._getJSONData()).toStrictEqual(expectedResult);
});

import한 model은 데이터베이스에 접근하는 함수들을 모아놓은 모듈이다.

 

 

 

11번째 줄의 spyOn함수를 통해서 mock 함수를 생성한다.

  const mockFindPasswordById = jest.spyOn(model, 'findPasswordById');

 

import된 model 모듈에 존재하는,

findPasswordById 함수를 spyOn을 통해서,

mockFindPasswordById 라는,

mock함수로 만들었다.

 

 

findPasswordById는 원래 데이터베이스에 접근하는 함수였지만 이제 아무런 기능이 없는 mock 함수가 되었다.

호출하면 undefined만 반환한다.

 

 

우리는 이 함수가 데이터베이스에서 password를 조회해 반환해주기를 원한다. 그렇기에 우리가 원하는 반환 값을 직접 달아준다.

  mockFindPasswordById.mockResolvedValue(expectedResult);

이제 mockFindPasswordById는 쿡 찌르면 우리가 지정해준 응답값을 반환해준다.

이는 "데이터베이스가 정상적으로 동작했다고 치자"라고 설정한것이다.

 

 

데이터베이스 관련 함수는 async/await으로 처리되기에 Promise를 resolve하는 mockResolvedValue를 사용했다. 

다른 여러 종류의 반환값에 대한 반환값 지정 함수는 다음 문서에서 확인하면 된다.

https://jestjs.io/docs/mock-function-api

 

Mock Functions · Jest

Mock functions are also known as "spies", because they let you spy on the behavior of a function that is called indirectly by some other code, rather than only testing the output. You can create a mock function with jest.fn(). If no implementation is given

jestjs.io

 

 

 

이제 우리는 UserController.getPassword(req, res, next)로 우리가 만들었던 req를 보내준다.

UserController.getPassword(req, res, next)에는 findPasswordById함수가 있는데 우리는 지금 이걸 mock으로 만들어 주었으니 무조건 우리가 지정한 응답을 줄 것이다.

  await UserController.getPassword(req, res, next);

  expect(res.statusCode).toBe(200);
  expect(res._getJSONData()).toStrictEqual(expectedResult);

UserController.getPassword에 잘못된 점이 없다면 테스트는 성공한다.

 

 

 

 

4. 결론

우리는 이제 무엇에 테스트를 성공한것인가?

 

 

UserController의 getPassword함수에서 

  1. req로부터 사용자 id를 추출하고,
  2. findPasswordById함수에 사용자 id를 넘겨준다.

이 두가지 로직에 대한 검증이 성공한 것이다.

(매우매우 간단한 테스트다)

 

 

이는 작은 단위의 단위테스트이고 supertest를 활용한 통합테스트도 작성하였다. 오히려 통합테스트가 더 직관적이라 테스트코드를 처음 작성해보는 나에겐 더 쉬웠다.

라고 말하지만 사실 통합테스트에서는 db의 endpoint를 test일 경우에만 local db로 동적으로 바꿔주는 작업 때문에 많이 고생했다.ㅠㅠ

다음 포스팅에서 하소연 해보겠다.

 

 

 

 

 

참고:

tsc --init

을 통해 생성하는 tsconfig.json파일.

타입스크립트를 시작한지 얼마되지 않았기에 대충 설정파일이구나 하고 넘겼다. 

여기 있는 설정들을 살펴보며 타입스크립트의 전체적인 그림을 살펴보자.


1. 멋모르고 사용해 온 설정

처음 init을 통해 파일을 생성하면 뭔가 엄청 많다.

이거처럼

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

    /* Projects */
    // "incremental": true,                              /* Enable incremental compilation */
    // "composite": true,                                /* Enable constraints that allow a TypeScript project to be used with project references. */
    // "tsBuildInfoFile": "./",                          /* Specify the folder for .tsbuildinfo incremental compilation files. */
    // "disableSourceOfProjectReferenceRedirect": true,  /* Disable preferring source files instead of declaration files when referencing composite projects */
    // "disableSolutionSearching": true,                 /* Opt a project out of multi-project reference checking when editing. */
    // "disableReferencedProjectLoad": true,             /* Reduce the number of projects loaded automatically by TypeScript. */

    /* Language and Environment */
    "target": "es5",                                     /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */
    // "lib": [],                                        /* Specify a set of bundled library declaration files that describe the target runtime environment. */
    // "jsx": "preserve",                                /* Specify what JSX code is generated. */
    // "experimentalDecorators": true,                   /* Enable experimental support for TC39 stage 2 draft decorators. */
    // "emitDecoratorMetadata": true,                    /* Emit design-type metadata for decorated declarations in source files. */
    // "jsxFactory": "",                                 /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h' */
    // "jsxFragmentFactory": "",                         /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */
    // "jsxImportSource": "",                            /* Specify module specifier used to import the JSX factory functions when using `jsx: react-jsx*`.` */
    // "reactNamespace": "",                             /* Specify the object invoked for `createElement`. This only applies when targeting `react` JSX emit. */
    // "noLib": true,                                    /* Disable including any library files, including the default lib.d.ts. */
    // "useDefineForClassFields": true,                  /* Emit ECMAScript-standard-compliant class fields. */

    /* Modules */
    "module": "commonjs",                                /* Specify what module code is generated. */
    // "rootDir": "./",                                  /* Specify the root folder within your source files. */
    // "moduleResolution": "node",                       /* Specify how TypeScript looks up a file from a given module specifier. */
    // "baseUrl": "./",                                  /* Specify the base directory to resolve non-relative module names. */
    // "paths": {},                                      /* Specify a set of entries that re-map imports to additional lookup locations. */
    // "rootDirs": [],                                   /* Allow multiple folders to be treated as one when resolving modules. */
    // "typeRoots": [],                                  /* Specify multiple folders that act like `./node_modules/@types`. */
    // "types": [],                                      /* Specify type package names to be included without being referenced in a source file. */
    // "allowUmdGlobalAccess": true,                     /* Allow accessing UMD globals from modules. */
    // "resolveJsonModule": true,                        /* Enable importing .json files */
    // "noResolve": true,                                /* Disallow `import`s, `require`s or `<reference>`s from expanding the number of files TypeScript should add to a project. */

    /* JavaScript Support */
    // "allowJs": true,                                  /* Allow JavaScript files to be a part of your program. Use the `checkJS` option to get errors from these files. */
    // "checkJs": true,                                  /* Enable error reporting in type-checked JavaScript files. */
    // "maxNodeModuleJsDepth": 1,                        /* Specify the maximum folder depth used for checking JavaScript files from `node_modules`. Only applicable with `allowJs`. */

    /* Emit */
    // "declaration": true,                              /* Generate .d.ts files from TypeScript and JavaScript files in your project. */
    // "declarationMap": true,                           /* Create sourcemaps for d.ts files. */
    // "emitDeclarationOnly": true,                      /* Only output d.ts files and not JavaScript files. */
    "sourceMap": true,                                /* Create source map files for emitted JavaScript files. */
    // "outFile": "./",                                  /* Specify a file that bundles all outputs into one JavaScript file. If `declaration` is true, also designates a file that bundles all .d.ts output. */
    "outDir": "dist",                                   /* Specify an output folder for all emitted files. */
    // "removeComments": true,                           /* Disable emitting comments. */
    // "noEmit": true,                                   /* Disable emitting files from a compilation. */
    // "importHelpers": true,                            /* Allow importing helper functions from tslib once per project, instead of including them per-file. */
    // "importsNotUsedAsValues": "remove",               /* Specify emit/checking behavior for imports that are only used for types */
    // "downlevelIteration": true,                       /* Emit more compliant, but verbose and less performant JavaScript for iteration. */
    // "sourceRoot": "",                                 /* Specify the root path for debuggers to find the reference source code. */
    // "mapRoot": "",                                    /* Specify the location where debugger should locate map files instead of generated locations. */
    // "inlineSourceMap": true,                          /* Include sourcemap files inside the emitted JavaScript. */
    // "inlineSources": true,                            /* Include source code in the sourcemaps inside the emitted JavaScript. */
    // "emitBOM": true,                                  /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */
    // "newLine": "crlf",                                /* Set the newline character for emitting files. */
    // "stripInternal": true,                            /* Disable emitting declarations that have `@internal` in their JSDoc comments. */
    // "noEmitHelpers": true,                            /* Disable generating custom helper functions like `__extends` in compiled output. */
    // "noEmitOnError": true,                            /* Disable emitting files if any type checking errors are reported. */
    // "preserveConstEnums": true,                       /* Disable erasing `const enum` declarations in generated code. */
    // "declarationDir": "./",                           /* Specify the output directory for generated declaration files. */

    /* Interop Constraints */
    // "isolatedModules": true,                          /* Ensure that each file can be safely transpiled without relying on other imports. */
    // "allowSyntheticDefaultImports": true,             /* Allow 'import x from y' when a module doesn't have a default export. */
    "esModuleInterop": true,                             /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables `allowSyntheticDefaultImports` for type compatibility. */
    // "preserveSymlinks": true,                         /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */
    "forceConsistentCasingInFileNames": true,            /* Ensure that casing is correct in imports. */

    /* Type Checking */
    "strict": true,                                      /* Enable all strict type-checking options. */
    // "noImplicitAny": true,                            /* Enable error reporting for expressions and declarations with an implied `any` type.. */
    // "strictNullChecks": true,                         /* When type checking, take into account `null` and `undefined`. */
    // "strictFunctionTypes": true,                      /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */
    // "strictBindCallApply": true,                      /* Check that the arguments for `bind`, `call`, and `apply` methods match the original function. */
    // "strictPropertyInitialization": true,             /* Check for class properties that are declared but not set in the constructor. */
    // "noImplicitThis": true,                           /* Enable error reporting when `this` is given the type `any`. */
    // "useUnknownInCatchVariables": true,               /* Type catch clause variables as 'unknown' instead of 'any'. */
    // "alwaysStrict": true,                             /* Ensure 'use strict' is always emitted. */
    // "noUnusedLocals": true,                           /* Enable error reporting when a local variables aren't read. */
    // "noUnusedParameters": true,                       /* Raise an error when a function parameter isn't read */
    // "exactOptionalPropertyTypes": true,               /* Interpret optional property types as written, rather than adding 'undefined'. */
    // "noImplicitReturns": true,                        /* Enable error reporting for codepaths that do not explicitly return in a function. */
    // "noFallthroughCasesInSwitch": true,               /* Enable error reporting for fallthrough cases in switch statements. */
    // "noUncheckedIndexedAccess": true,                 /* Include 'undefined' in index signature results */
    // "noImplicitOverride": true,                       /* Ensure overriding members in derived classes are marked with an override modifier. */
    // "noPropertyAccessFromIndexSignature": true,       /* Enforces using indexed accessors for keys declared using an indexed type */
    // "allowUnusedLabels": true,                        /* Disable error reporting for unused labels. */
    // "allowUnreachableCode": true,                     /* Disable error reporting for unreachable code. */

    /* Completeness */
    // "skipDefaultLibCheck": true,                      /* Skip type checking .d.ts files that are included with TypeScript. */
    "skipLibCheck": true                                 /* Skip type checking all .d.ts files. */
  },
  "include": [
    "src/**/*"
  ],
  "exclude":[
    "node_modules"
  ]
}

 

이 많은 옵션들 중 내가 사용하고 있는 옵션들부터 살펴보겠다.

 

 

 

compilerOptions과 include/exclude

tsconfig.json 파일을 살펴보면 최상위 경로는 compilerOptions, include, exclude 프로퍼티로 구성돼있다.

 

  • compilerOptions은 말 그대로 어떤 컴파일 설정을 사용할지에 대한 속성이다.
  • include는 프로그램에 포함하고 싶은 파일들의 목록을 지정한다. 보통
{
    "include": ["src/**/*", "tests/**/*"]
}

이와 같은 형태로 표기하여 한번에 지정한다.

  • exclude는 include에 속한 파일들 중에서 제외시킬 파일들을 지정한다. 이때 프로그램에서 포함되지 않도록 제외시키는 메커니즘이 아니라 단순이 include 프로퍼티에서만 제외시킨다는 것에 주의하자.

 

 

"target"

여기서부터는 compilerOptions 프로퍼티 내부의 옵션들이다.

target은 어떤 버전의 자바스크립트로 컴파일할지 지정한다.

ts파일들을 tsc로 컴파일하여 js로 만드는 과정이 필요한 타입스크립트이기에 실제 런타임은 대부분 js다. 이 js의 버전을 지정한다.

 

 

"module"

프로그램에서 사용할 모듈 시스템을 결정한다. 즉, 모듈 내보내기/불러오기 코드가 어떠한 방식의 코드로 컴파일 될지를 결정한다.

 

 

"lib"

타입스크립트 파일을 자바스크립트로 컴파일 할 때 포함될 라이브러리의 목록이다. 이렇게만 하면 와닿지 않는데, async코드를 컴파일 할 때 Promise객체가 필요하므로 "es2017"과 같은 항목을 넣어준다고 한다.

 

 

"baseUrl"

비상대적 import의 모듈 해석시 기준이 되는 경로를 지정한다. 예시를 들자면, 프로젝트의 루트 디렉토리에 존재하는 src 디렉토리를 기준으로 각종 모듈들을 불러오고 싶다면 이 프로퍼티를 './src'로 지정한다.

 

 

"typeRoots'

기본적으로 @types 패키지들은 컴파일 목록에 포함된다. 하지만 만약 typeRoots 프로퍼티가 특정 경로들로 지정돼 있다면 오직 그 경로에 존재하는 패키지들만 컴파일 목록에 포함된다. 

 

 

"paths"

baseUrl 기준으로 상대 위치로 가져오기를 다시 매핑하는 항목 설정

 

 

"emitDecoratorMetadata"

"experimentalDecorators"

데코레이터 설정이다. 데코레이터란, 자바의 어노테이션과 비슷한 느낌의 기능으로, 데코레이터가 붙은 클래스, 메소드 등에 데코레이터에서 지정한 기능이 동작하도록 하는 기능이다.

 

 

"allowSyntheticDefaultImports"

불러오려는 모듈에 default export가 없어도 import * as XXX가 아닌 import XXX로 사용할 수 있게 해주는 설정이다.

 

 

"forceConsistentCasingInFileNames"

사용할 파일의 이름을 대소문자까지 정확하게 작성하도록 강제하는 설정이다.

 

 

"moduleResolution"

모듈 해석 전략을 결정한다. Nodejs 방식대로 모듈 해석을 하려면 "Node"를, 1.6버전 이전의 타입스크립트에서 사용하던 방식대로 모듈을 해석하려면 "Classic"을 입력한다.

 

 

"pretty"

에러와 메시지를 색과 컨텍스트를 사용해서 스타일을 지정하는 옵션

 

 

"sourceMap"

빌드시 map파일을 생성할지 설정한다. 생성된 소스맵 파일은 크롬 개발자 도구로 디버깅에 사용된다.

 

 

"allowJs"

js파일을 허용하는 옵션이다.타입스크립트는 .js확장자를 허용하지 않는다. 이에대한 예외를 허락하는 옵션.

 

 

"esModuleInterop"

"모든 가져오기에 대한 네임스페이스 객체 생성을 통해 CommonJS와 ES 모듈 간의 상호 운용성을 제공"이라고 돼있는데 이게 무슨소리인가?

피부에 와닿진 않지만 Commonjs방식으로 내보낸 모듈을 es모듈 방식의 import로 가져올 수 있게 해주는 기능 정도로 일단 이해하자.

 

 

"outDir"

컴파일 후 생성되는 js파일이 생성될 폴더를 지정한다

 

 

 

 

 

참고:

더보기

 

노드자스를 통해 개발하면서 무지성 install들을 해온것 같다. 그래서 정리해보는 시간.


1. npm options

기본적으로 npm install은 ./node_modules 폴더에 패키지를 다운받는 명령어이다.

나는 주로 npm 쓸때 접미사로 -D, -S, -g 옵션을 썼다. 왜냐? 블로그에서 이렇게 하라고 해서..

무슨 차이인지 모르고 써왔고 이거 때문에 문제 생긴적은 없었지만 찾아보니 나름의 의미가 있더라.

 

package.json을 보면 dependencies와 devDependencies가 있는데 얘네 중 어느쪽에 속하게 하도록 구분짓는 용도였다.

  • dependencies: 프로덕션 환경에서 응용 프로그램에 필요한 패키지.
  • devDependencies: 로컬 개발 및 테스트에만 필요한 패키지.

어떤 라이브러리가 빌드타임에 필요하면 devDependencies에 넣고, 런타임에도 필요하면 dependencies에도 넣어준다.

즉, 배포용 패키지와 개발용 패키지의 차이

 

--save는 package.json에 의존성 항목에 추가하도록 해주는 옵션인데 npm5부터는 굳이 --save 쓰지 않아도 된다고 한다.

 

 

 

-P/--save-dev (default)

암튼 그래서 

npm install [패키지명] -P
npm install [패키지명] --save-prod

이렇게 옵션을 주면 패키지를 설치하고 dependencies 목록에 추가한다.

사실 디폴트 옵션이라 아무 옵션도 안써주는 경우가 이 경우였다.

npm install [패키지명]

 

-D/--save-dev

그렇다면 얘는 devDependencies에 속하도록 하는 옵션이다.

npm install [패키지명] -D
npm install [패키지명] --save-dev

 

 

-g/--global

전역모드로 설치하면 시스템 폴더에 패키지를 설치하게 된다. -g를 통해서 설치하면 package.json의 의존성 목록에 기록되지 않는다.

-P나 -D는 지역설치옵션으로 루트 디렉토리의 node_modules에 설치된다. 이렇게 설치된 패키지는 해당 프로젝트 내에서만 사용 가능하다.

-g를 통한 전역설치를 하면 모든 프로젝트에서 사용 가능하다.

npm install [패키지명] -g
npm install [패키지명] -global

 

 

 

이 내용들을 어떤 분이 친절하게 정리해 두셨다.

npm install
// package.json의 dependencies에 있는 모든 패키지를 설치한다.
// 처음 프로젝트를 세팅했다면 이 명령어로 패키지를 설치하고 개발을 시작하면 된다.

npm i
// npm install 의 줄인 명령어. 

npm install [package]
// 현재 작업중인 디렉토리 내에 있는 ./node_modules에 [package]를 설치한다. 
// (예: npm install moment) -> ./node_modules에 moment 패키지를 설치 함

npm install [package] --save
// [package]를 설치 하면서 package.json파일에 있는 dependencies 객체에 지금 설치한 패키지 정보를 추가한다.

npm install [package] --save -dev
// --save옵션과 같이 package.json파일에 의존성 내용을 추가하지만
// dependencies가 아닌 devDepenencies 객체에 추가한다.
–save와 –save-dev의 차이는 의존성을 기본으로 추가할지, 개발용으로 추가할지의 차이이다.
--production로 빌드할 경우 devDepenencies에 있는 패키지들은 설치되지 않는다

npm install [package] --no-save
// dependencies에 패키지 정보를 추가하지 않는다.

npm install [package] --save-exact
// 정확히 일치하는 버전의 패키지를 추가한다.

npm install [package] --save-bundle
// 해당 패키지를 bundleDependencies에 추가한다.

npm install [package] --force
// 해당 패키지가 존재하더라도 원격 저장소에 있는 패키지를 가져온다.

 

 

 

 

 

참고:

 

거의 한달만에 글을 쓴다. 그간 참 많은 일이 있었다. 

취업을 했고 킥보드를 타다가 응급실도 갔다. 너무 정신없는 시간이었다고 변명을 해본다ㅠ

요즘은 TypeScript+Express를 통한 개발을 하고있는데 Express 사용시 헤더의 설정을 통하여 웹 취약점으로부터 서버를 보호해주는 보안 모듈인 Helmet이라는 것을 알게 됐고 간단하게 정리해보겠다.

(헤더에 씌운다고해서 이름이 헬멧인 것이 너무 귀엽다. 내 머리에 헬멧을 썻어야했는데 익스프레스에만 씌우고있다 아ㅋㅋ)

 


1. 사용법

그냥 한 줄만 추가해 주면 된다.

npm install helmet

헬멧을 설치하고,

 

 

const helmet = require('helmet');
const express = require('express');
const app = express();

app.use(helmet());

이렇게 붙혀주기만 하면 된다.

 

 

 

 


2. 무엇을 보호해주는가?

세부적인 미들웨어 함수들을 포함하고있는 헬멧은 다음과 같은 기능이 있다.

 

1. csp

csp는 Content-Security-Policy이다. 브라우저에서 사용하는 컨텐츠 기반의 보안 정책으로 XSS나 Data Injection, Click Jacking 등 웹 페이지에 악성 스크립트를 삽입하는 공격기법들을 막기 위해 사용된다.

 

2. hidePoweredBy

헤더에서 X-Powered-By를 제거한다. 이는 서버에 대한 정보를 제공해주는 역할로 나 같은 경우는 이 영역에 Express라고 표기됨을 확인할 수 있었다. 이 정보는 악의적으로 활용될 가능성이 높기에 헬멧을 통해서 제거해 주는 것이 좋다.

 

3. HSTS

HTTP Strict Transport Security의 약자로 웹 사이트에 접속할 때 강제적으로 HTTPS로 접속하게 강제하는 기능이다. 

사용자가 특정 사이트에 접속할 때 해당 사이트가 HTTPS를 지원하는지, 하지 않는지를 미리 모르는 경우가 대부분이다. 그렇기에 브라우저는 디폴트로 HTTP로 먼저 접속을 시도한다. 이때 HTTPS로 지원되는 사이트였다면 301Redirect나 302 Redirect를 응답하여 HTTPS로 다시 접속하도록 한다.

 

하지만 이때 해커가 중간자 공격을 하여, 중간에 프록시 서버를 두고

[나] <-> [해커] 사이에서는 HTTP 통신을 하고 [해커] <-> [웹사이트] 사이에선 HTTPS 통신을 한다면,

우리의 개인정보가 HTTP 프로토콜을 통해 해커에게로 전해지는 참사가 일어난다.

이러한 공격을 SSL Stripping이라고 하며 이런 공격을 방지하기 위해 HSTS를 설정한다.

 

4. IeNoOpen

IE8 이상에 대해 X-Download-Options를 설정한다. 이 옵션은 8 버전 이상의 인터넷 익스플로러에서 다운로드된 것들을 바로 여는 것 대신 저장부터 하는 옵션이다. 사용자는 저장부터 하고 다른 응용프로그램에서 열어야 한다.

 

5. noCache

클라이언트 측에서 캐싱을 사용하지 않도록 하는 설정이다. 

 

6. noSniff

X-Content-Type-Options 를 설정하여 선언된 콘텐츠 유형으로부터 벗어난 응답에 대한 브라우저의 MIME 스니핑을 방지한다. MIME이란 Multipurpose Internet Mail Extensions의 약자로 클라이언트에게 전송된 문서의 다양성을 알려주기 위한 포맷이다. 브라우저는 리소스를 내려받을 때 MIME 타입을 보고 동작하기에 정확한 설정이 중요하다.

 

MIME 스니핑이란 브라우저가 특정 파일을 읽을 때 파일의 실제 내용과 Content-Type에 설정된 내용이 다르면 파일로 부터 형식을 추측하여 실행하는 것인데, 편리함을 위한 기능이지만 공격자에게 악용 될 가능성이 있다.

 

7. frameguard

 X-Frame-Options 헤더를 설정하여 클릭재킹에 대한 보호를 제공한다.

클릭재킹이란 사용자가 자신이 클릭하고 있다고 인지하는 것과 다른 것을 클릭하도록 하여 속이는 해킹 기법이다. 속이기 위해 보이지 않는 레이어에 보이지 않는 버튼을 만드는 방법이 있다.

 

8. xssFilter

xss필터는 xss필터.

 

 

 

 

 

 

 

 

 

 

 

참고:

더보기

+ Recent posts