본문 바로가기

Front/React

[React] 백엔드 프로그래밍: Node.js의 Koa 프레임워크

1. 소개하기

1.1. 백엔드

  • 서버를 만들어 데이터를 여러 사람과 공유한다.
  • 데이터를 담을 때 여러 가지 규칙이 필요
    • 사용자 인증, 데이터 종류 구분, 데이터 검증 등
    • 어떤 종류의 데이터를 몇 개씩 보여줄지
  • 백엔드 프로그래밍 다양한 언어로 구현 가능
    • PHP, 파이썬, Golang, 자바, 자바스크립트
    • 자바스크립트로 서버를 구현할 수 있는 Node.js를 사용해보자

1.2. Node.js

  • 처음엔 자바스크립트를 웹 브라우저에서만 사용했다.
  • 시간이 지나면서 자바스크립트는 계속 발전했으며, 구글이 크롬 웹 브라우저를 소개하면서 V8이라는 자바스크립트 엔진도 공개했다.
  • 이 자바스크립트 엔진을 기반으로 웹 브라우저뿐만 아니라 서버에서도 자바스크립트를 사용할 수 있는 런타임을 개발했다. 이것이 바로 Node.js이다.

1.3. Koa

  • Node.js 환경에서 보통 Express, Hapi, Koa등의 웹 프레이;ㅁ워크 사용
  • Koa는 Express의 기존 개발 팀이 개발한 프레임워크
  • Express는 미들웨어, 라우팅, 템플릿, 파일 호스팅 등과 같은 다양한 기능이 자체적으로 내장되어 있는 반면, Koa는 미들웨어 기능만 갖추고 있으며 나머지는 다은 라이브러리를 적용하여 사용
    • 즉, Koa는 우리가 필요한 기능들만 붙여서 서버를 만들 수 있기 때문에 Express보다 훨씬 가볍다.
    • Koa는 async/await 문법을 정식으로 지원하기 때문에 비동기 작업을 더 편하게 관리할 수 있다.

2. 작업 환경 준비

2.1. Node 설치 확인

$ node --version

2.2. 프로젝트 생성

$ yarn init -y

$ yarn add koa

 

2.3. ESLint와 Prettier 설정

 

 

[.prettierrc]

{
    "singleQuote": true,
    "semi": true,
    "useTabs": false,
    "tabWidth": 2,
    "trailingComma": "all",
    "printWidth": 80
}

 

다음으로 Prittier에서 관리하는 코드 스타일은 ESLint에서 관리하지 않도록 eslint-config-prettier를 설치하여 적용하세요.

 

$ yarn add eslint-config-prettier

 

[.eslintrc.json]

{
    "env": {
        "commonjs": true,
        "es6": true,
        "node": true
    },
    "extends": ["eslint:recommended", "prettier"],
    "globals": {
        "Atomics": "readonly",
        "SharedArrayBuffer": "readonly"
    },
    "parserOptions": {
        "ecmaVersion": 2018
    },
    "rules": {
    }
}

 

이제 두 도구가 제대로 작동하는지 확인해보자

 

[src/index.js]

const hello = 'hello';
  • const 값을 선언하고 사용하지 않으면, ESLint 기본 설정은 이를 에러로 간주한다.

  • 사용되지 않는 const 값은 문법적으로 문제없지만, 더 나은 코드를 작성하도록 장려하기 위해 ESLint는 이를 오류로 취급한다.
  • 이러한 규칙을 끌 수 있다.
    • 오류 이름을 알아두면 .eslintrc.json에서 해당 오류를 경고로 바꾸거나 비활성화할 수 있다.
    • .eslintrc.json을 다음과 같이 수정해보자

[.eslintrc.json]

{
    "env": {
        "commonjs": true,
        "es6": true,
        "node": true
    },
    "extends": ["eslint:recommended", "prettier"],
    "globals": {
        "Atomics": "readonly",
        "SharedArrayBuffer": "readonly"
    },
    "parserOptions": {
        "ecmaVersion": 2018
    },
    "rules": {
        "no-unused-vars": "warn",
        "no-console": "off"
    }
}

 

  • 경고문으로 바뀌어 나온다.
  • ESLint기본 설정에서는 console.log를 사용하는 것을 지양하고 있다.
  • 그러나 이번식습에서 console.log를 사용할 것이므로 이 규칙을 비활성화했다.
  • 또한, 저장할 때 "가 '로 바뀌었는지도 확인해보자

3. Koa 기본 사용법

3.1. 서버 띄우기

먼저 서버를 여는 방법부터 알아보자

 

[index.js]

const Koa = require('koa');

const app = new Koa();

app.use((ctx) => {
  ctx.body = 'hello world';
});

app.listen(4000, () => {
  console.log('Listening to port 4000');
});

 

  • 서버를 포트 4000번으로 열고, 서버에 접속하면 'hello worl'라는 텍스트를 반환하도록 설정했다.
  • $ node src (서버 실행)
  • 원래 node를 통해 자바스크립트 파일을 실행할 때는 node src/index.js와같이 전체 경로를 입력하는 것이 맞지만, index.js 파일은 예외로 디렉터리까지만 입력해도 실행할 수 있다.

 

3.2. 미들웨어

  • Koa 애플리케이션은 미들웨어의 배열로 구성되어있다.
  • app.use함수는 미들웨어 함수를 애플리케이션에 등록한다.
  • 미들웨어 함수는 다음과 같은 구조로 이루어져 있다.
(ctx, next) => {
}
  • Koa의 미들웨어 함수는 두 개의 파라미터를 받는다.
    • 첫 번째 : ctx -> Context의 줄임말로 웹 요청과 응답에 관한 정보를 지니고 있다.
    • 두 번째 : next -> 현재 처리중인 미들웨어의 다음 미들웨어를 호출. 미들웨어를 등록하고 next 함수를 호출하지 않으면, 그다음 미들웨어를 처리하지 않는다.
  • 미들웨어는 app.use를 사용하여 등록되는 순서대로 처리된다. 
  • 다음과 같이 현재 요청을 받은 주소와 우리가 정해 준 숫자를 기록하는 두 개의 미들웨어를 작성해보자

[index.js]

const Koa = require('koa');

const app = new Koa();

app.use((ctx, next) => {
  console.log(ctx.url);
  console.log(1);
  next();
});

app.use((ctx, next) => {
  console.log(2);
  next();
});

app.use((ctx) => {
  ctx.body = 'hello world';
});

app.listen(4000, () => {
  console.log('Listening to port 4000');
});

 

다시 node src 명령어로 실행 > http://localhost:4000/

 

  • 크롬 브라우저는 사용자가 웹 페이지에 들어가면 해당 사이트의 아이콘 파일인 /favicon.ico파일을 서버에 요청하기 때문에 결과에 /경로도 나타나고 /favico.ico경로도 나타난다.
  • 이번에는 첫 번째 미들웨어에서 호출하던 next 함수를 주석으로 처리해보자

[index.js]

const Koa = require('koa');

const app = new Koa();

app.use((ctx, next) => {
  console.log(ctx.url);
  console.log(1);
  //   next();
});

app.use((ctx, next) => {
  console.log(2);
  next();
});

app.use((ctx) => {
  ctx.body = 'hello world';
});

app.listen(4000, () => {
  console.log('Listening to port 4000');
});

 

  • next를 호출하지 않으니 첫 번째 미들웨어까지만 실행하고 그 아래에 있는 미들웨어는 모두 무시되었다.
  • 이런 속성을 사용하여 조선부로 다음 미들웨어 처리를 무시하게 만들 수 있다.
  • 다음 코드에서 요청 경로에 authorized=1 이라는 쿼리 파라미터가 포함되어 있으면 이후 미들웨어를 처리해주고, 그렇지 않으면 이후 미들웨어를 처리하지 않는다.

[index.js]

const Koa = require('koa');

const app = new Koa();

app.use((ctx, next) => {
  console.log(ctx.url);
  console.log(1);
  if (ctx.query.authorized !== '1') {
    ctx.status = 401; // Unauthorized
    return;
  }
  next();
});

app.use((ctx, next) => {
  console.log(2);
  next();
});

app.use((ctx) => {
  ctx.body = 'hello world';
});

app.listen(4000, () => {
  console.log('Listening to port 4000');
});
  • 쿼리 파라미터는 문자열이기 때문에 비교할 때는 꼭 문자열 형태로 비교해야 한다.
  • 이제 서버를 재시작한 뒤 다음 링크에 들어가서 어떤 결과가 나타나는지 확인해보자
    • http://localhost:4000/
    • http://localhost:4000/authorized=1

 

 

 

3.2.1. next 함수는 Promise를 반환

  • next 함수를 호출하면 Promise를 반환
  • 이는 Koa가 Express와 차별화 되는 부분
  • next 함수가 반환하는 Promise는 다음에 처리해야 할 미들웨어가 끝나야 완료된다.
  • 다음과 같이 next 함수 호출 이후에 then을 사용하여 Promise가 끝난 다음 콘솔에 END를 기록하도록 수정해보자

[index.js]

const Koa = require('koa');

const app = new Koa();

app.use((ctx, next) => {
  console.log(ctx.url);
  console.log(1);
  if (ctx.query.authorized !== '1') {
    ctx.status = 401; // Unauthorized
    return;
  }
  next().then(() => {
    console.log('END');
  });
});

app.use((ctx, next) => {
  console.log(2);
  next();
});

app.use((ctx) => {
  ctx.body = 'hello world';
});

app.listen(4000, () => {
  console.log('Listening to port 4000');
});

 

3.2.2. async/await 사용하기

  • Koa는 async/await를 정식으로 지원하기 때문에 해당 문법을 아주 편하게 사용할 수 있다.
  • 기존 코드를 async/await를 사용하는 형태로 한번 수정해보자

[index.js]

const Koa = require('koa');

const app = new Koa();

app.use(async (ctx, next) => {
  console.log(ctx.url);
  console.log(1);
  if (ctx.query.authorized !== '1') {
    ctx.status = 401; // Unauthorized
    return;
  }
  await next();
  console.log('END');
});

app.use((ctx, next) => {
  console.log(2);
  next();
});

app.use((ctx) => {
  ctx.body = 'hello world';
});

app.listen(4000, () => {
  console.log('Listening to port 4000');
});

 

 

이전과 똑같이 작동한다.

4. nodemon 사용하기

  • 서버 코드를 변경할 때마다 서버를 재시작하는 것이 번거롭다.
  • nodemon이라는 도구를 사용하면 코드를 변경할 때마다 서버를 자동으로 재시작해준다.
  • $ yarn add --dev nodemon

[package.json]

{
  "name": "blog-backend",
  "version": "1.0.0",
  "main": "index.js",
  "license": "MIT",
  "dependencies": {
    "eslint-config-prettier": "^7.2.0",
    "koa": "^2.13.1"
  },
  "devDependencies": {
    "eslint": "^7.18.0",
    "nodemon": "^2.0.7"
  },
  "scripts": {
    "start": "node src",
    "start:dev": "nodemon --watch src/ src/index.js"
  }
}
  • start 스크립트에는 서버를 시작하는 명령어를 넣고, start:dev 스크립트에는 nodemon을 통해 서버를 실행해 주는 명령어를 넣었다.
  • 여기서 nodemon은 src 디렉터리를 주시하고 있다가 해당 디렉터리 내부의 어떤 파일이 변경되면, 이를 감지하여 src/index.js 파일을 재시작해준다.
  • 이제부터는 다음 명령어를 사용하여 서버를 시작할 수 있다.
    • $ yarn start # 재시작이 필요 없을 때
    • $ yarn start:dev # 재시작이 필요할 때
  • 기존에 실행 중이던 서버를 종료할 뒤 yarn start:dev 명령어를 실행하세요. 그 다음에 index.js에서 기존 미들웨어를 모두 제거해보세요

5. koa-router 사용하기

  • Koa를 사용할 때도 다른 주소로 요청이 들어올 경우 다른 작업을 처리할 수 있도록 라우터를 사용해야 한다.
  • Koa 자체에 이 기능이 내장되어 있지는 않으므로, koa-router 모듈을 설치해야 한다.
  • $ yarn add koa-router

5.1. 기본 사용법

[index.js]

const Koa = require('koa');
const Router = require('koa-router');

const app = new Koa();
const router = new Router();

// 라우터 설정
router.get('/', (ctx) => {
  ctx.body = '홈';
});
router.get('/about', (ctx) => {
  ctx.body = '소개';
});

// app 인스턴스에 라우터 적용
app.use(router.routes()).use(router.allowedMethods());

app.listen(4000, () => {
  console.log('Listening to port 4000');
});
  • router.get의 첫 번째 파라미터에는 라우트의 경로를 넣고, 두 번째 파라미터에는 해당 라우트에 적용할 미들웨어 함수를 넣는다.
  • 여기서 get 키워드는 해당 라우트에서사용할 HTTP메서드를 의미한다.
  • get대신에 post, put, delete등을 넣을 수 있다.

 

 

5.2. 라우트 파라미터와 쿼리

  • URL쿼리의 경우 /post/?id=10 같은 형식으로 요청했다면 해당 값을 ctx.query에서 조회할 수 있다.
  • 쿼리 문자열을 자동으로 객체 형태로 파싱해주므로 별도로 파싱 함수를 돌릴 필요가 없다.(문자열 형태의 쿼리 문자열을 조회해야 할 때는 ctx.querystring을 사용한다.)

[index.js]

const Koa = require('koa');
const Router = require('koa-router');

const app = new Koa();
const router = new Router();

// 라우터 설정
router.get('/', (ctx) => {
  ctx.body = '홈';
});

router.get('/about/:name?', (ctx) => {
  const { name } = ctx.params;
  // name의 존재 유무에 따라 다른 결과 출력
  ctx.body = name ? `${name}의 소개` : '소개';
});

router.get('/posts', (ctx) => {
  const { id } = ctx.query;
  // id의 존재 유무에 따라 다른 결과 출력
  ctx.body = id ? `포스트 #${id}` : '포스트 아이디가 없습니다.';
});

// app 인스턴스에 라우터 적용
app.use(router.routes()).use(router.allowedMethods());

app.listen(4000, () => {
  console.log('Listening to port 4000');
});
  • http://localhost:4000/about/react
  • http://localhost:4000/posts
  • http://localhost:4000/posts?id=10

 

 

 

5.3. REST API

  • 웹 애플리케이션을 만들려면 데이터베이스에 정보를 입력하고 읽어 와야 한다.
  • 그런데 웹 브라우저에서 데이터베이스에 직접 접속하면 보안상 안된다.
  • 그래서 REST API를 만들어서 사용
  • 클라이언트가 서버에 자신이 데이터를 조회, 생성, 삭제, 업데이트하겠다고 요청하면, 서버는 필요한 로직에 따라 데이터베이스에 접근하여 작업을 처리한다.
  • REST API는 요청 종류에 따라 다른 HTTP 메서드를 사용한다.
메서드 설명
GET 데이터를 조회할 때 사용
POST 데이터를 등록할 때 사용. 인증작업을 거칠 때 사용하기도 한다.
DELETE 데이터를 지울 때 사용
PUT 데이터를 새 정보로 통째로 교체할 때 사용
PATCH 데이터의 특정 필드를 수정할 때 사용

 

5.4. 라우트 모듈화

  • 라우터를 여러 파일에 분리시켜서 작성하고, 이를 불러와 적용하는 방법을 알아보자

[src/api/index.js]

const Router = require('koa-router');
const api = new Router();

api.get('/test', (ctx) => {
  ctx.body = 'test성공';
});

// 라우터를 내보낸다.
module.exports = api;

 

[src/index.js]

const Koa = require('koa');
const Router = require('koa-router');

const api = require('./api');

const app = new Koa();
const router = new Router();

// 라우터 설정
router.use('/api', api.routes()); // api 라우터 적용

// app 인스턴스에 라우터 적용
app.use(router.routes()).use(router.allowedMethods());

app.listen(4000, () => {
  console.log('Listening to port 4000');
});

 

 

5.5. posts 라우트 생성

[src/api/posts/index.js]

const Router = require('koa-router');
const { post } = require('..');
const posts = new Router();

const printInfo = (ctx) => {
  ctx.body = {
    method: ctx.method,
    path: ctx.path,
    params: ctx.params,
  };
};

posts.get('/', printInfo);
posts.post('/', printInfo);
posts.get('/:id', printInfo);
posts.delete('/:id', printInfo);
posts.put('/:id', printInfo);
posts.patch('/:id', printInfo);
module.exports = posts;
  • posts 라우터에 여러 종류의 라우트를 설정한 후 모두 printInfo함수를 호출하도록 설정했다.
  • 문자열이 아닌 JSON객체를 반환하도록 설정하고, 이 객체에는 현재 요청의 메소드, 경로, 파라미터를 담았다.
  • 코드를 완성한 후 api라우트에 posts라우트를 연결하세요. 연결하는 방법은 서버의 메인 파일에 api라우트를 적용하는 방법과 비슷하다.

[src/api/index.js]

const Router = require('koa-router');
const posts = require('./posts');

const api = new Router();

api.use('/posts', posts.routes());

// 라우터를 내보낸다.
module.exports = api;

 

 

5.5.1. postman 다운로드 후 테스트

 

 

 

5.5.2. 컨트롤러 파일 작성

  • 라우트를 작성하는 과정에서 특정 경로에 미들웨어를 등록할 때는 다음과 같이 두 번째 인자에 함수를 선언해서 바로 넣어 줄 수 있다.
router.get('/', ctx => {
});
  • 하지만 각 라우트 처리 함수의 코드가 길면 라우터 설정을 한눈에 보기 힘들다.
  • 그렇기 때문에 이 라우트 처리 함수들을 다른 파일로 따로 분리해서 관리할 수도 있다.
  • 이 라우트 처리 함수만 모아 놓은 파일을 컨트롤러라고 한다.
  • 자바스크립트 배열 기능만 사용하여 임시로 기능을 구현해보자
  • koa-bodyparser 미들웨어를 적용해야 한다.
    • 이 미들웨어는 POST/PUT/PATCH 같은 메서드의 Request Body에 JSON형식으로 데이터를 널어 주면, 이를 파싱하여 서버에서 사용할 수 있게 한다.
  • $ yarn add koa-bodyparser

[src/index.js]

const Koa = require('koa');
const Router = require('koa-router');
const bodyParser = require('koa-bodyparser');

const api = require('./api');

const app = new Koa();
const router = new Router();

// 라우터 설정
router.use('/api', api.routes()); // api 라우터 적용

// 라우터 적용 전에 bodyParser 적용
app.use(bodyParser());

// app 인스턴스에 라우터 적용
app.use(router.routes()).use(router.allowedMethods());

app.listen(4000, () => {
  console.log('Listening to port 4000');
});

 

[src/api/posts/posts.ctrl.js]

let postId = 1; // id의 초깃값입니다.

// posts 배열 초기 데이터
const posts = [
  {
    id: 1,
    title: '제목',
    body: '내용',
  },
];

/* 포스트 작성
POST /api/posts
{ title, body }
*/
exports.write = (ctx) => {
  // REST API의 request body는 ctx.request.body에서 조회할 수 있습니다.
  const { title, body } = ctx.request.body;
  postId += 1; // 기존 postId 값에 1을 더합니다.
  const post = { id: postId, title, body };
  posts.push(post);
  ctx.body = post;
};

/* 포스트 목록 조회
GET /api/posts
*/
exports.list = (ctx) => {
  ctx.body = posts;
};

/* 특정 포스트 조회
GET /api/posts/:id
*/
exports.read = (ctx) => {
  const { id } = ctx.params;
  // 주어진 id 값으로 포스트를 찾습니다.
  // 파라미터로 받아 온 값은 문자열 형식이니 파라미터를 숫자로 변환하거나,
  // 비교할 p.id 값을 문자열로 변경해야 합니다.
  const post = posts.find((p) => p.id.toString() === id);
  // 포스트가 없으면 오류를 반환합니다.
  if (!post) {
    ctx.status = 404;
    ctx.body = {
      message: '포스트가 존재하지 않습니다.',
    };
    return;
  }
  ctx.body = post;
};

/* 특정 포스트 제거
DELETE /api/posts/:id
*/
exports.remove = (ctx) => {
  const { id } = ctx.params;
  // 해당 id를 가진 post가 몇 번째인지 확인합니다.
  const index = posts.findIndex((p) => p.id.toString() === id);
  // 포스트가 없으면 오류를 반환합니다.
  if (index === -1) {
    ctx.status = 404;
    ctx.body = {
      message: '포스트가 존재하지 않습니다.',
    };
    return;
  }
  // index번째 아이템을 제거합니다.
  posts.splice(index, 1);
  ctx.status = 204; // No Content
};

/* 포스트 수정(교체)
PUT /api/posts/:id
{ title, body }
*/
exports.replace = (ctx) => {
  // PUT 메서드는 전체 포스트 정보를 입력하여 데이터를 통째로 교체할 때 사용합니다.
  const { id } = ctx.params;
  // 해당 id를 가진 post가 몇 번째인지 확인합니다.
  const index = posts.findIndex((p) => p.id.toString() === id);
  // 포스트가 없으면 오류를 반환합니다.
  if (index === -1) {
    ctx.status = 404;
    ctx.body = {
      message: '포스트가 존재하지 않습니다.',
    };
    return;
  }
  // 전체 객체를 덮어씌웁니다.
  // 따라서 id를 제외한 기존 정보를 날리고, 객체를 새로 만듭니다.
  posts[index] = {
    id,
    ...ctx.request.body,
  };
  ctx.body = posts[index];
};

/* 포스트 수정(특정 필드 변경)
PATCH /api/posts/:id
{ title, body }
*/
exports.update = (ctx) => {
  // PATCH 메서드는 주어진 필드만 교체합니다.
  const { id } = ctx.params;
  // 해당 id를 가진 post가 몇 번째인지 확인합니다.
  const index = posts.findIndex((p) => p.id.toString() === id);
  // 포스트가 없으면 오류를 반환합니다.
  if (index === -1) {
    ctx.status = 404;
    ctx.body = {
      message: '포스트가 존재하지 않습니다.',
    };
    return;
  }
  // 기존 값에 정보를 덮어씌웁니다.
  posts[index] = {
    ...posts[index],
    ...ctx.request.body,
  };
  ctx.body = posts[index];
};
  • 컨트롤러를 만들면서 exports.이름 = ... 형식으로 함수를 내보내 주었다.
  • 이렇게 내보낸 코드는 다음 형식으로 불러올 수 있다.
const 모듈이름 = require('파일이름');
모듈이름.이름();

 

  • require('/posts.ctrl')을 입력하여 방금 만든 posts.ctrl.js파일을 불러온다면 다음 객체를 불러오게 된다.
{
   white: Function,
   list: Function,
   read: Function,
   remove: Function,
   replace: Function,
   update: Function,
};

 

컨트롤러 함수를 각 라우트에 연결시켜 보자

 

[src/api/posts/index.js]

const Router = require('koa-router');
const postCtrl = require('./post.ctrl');

const posts = new Router();

posts.get('/', postCtrl.list);
posts.post('/', postCtrl.write);
posts.get('/:id', postCtrl.read);
posts.delete('/:id', postCtrl.remove);
posts.put('/:id', postCtrl.replace);
posts.patch('/:id', postCtrl.update);
module.exports = posts;

 

 

 

patch와 put의 차이

PATCH : 기존 body 내용을 유지하며, Request Body로 전달한 title 값만 변경

 

PUT: 기존 body가 사라진다.