외주 레거시로부터의 여정 3편 - API 개편

백엔드 개편

API

외주 개발사가 개발한 API는 그야말로 순수한 Express에 자체 미들웨어로 중무장한 상태였다. 겉으로 포장만 안했지 일종의 외주사 전용 프레임워크라고 해도 무방했다. 그런데 말입니다.

유닛 테스트가 없다

테스트가 없으니 물론 커버리지도 0%였다. 테스트가 없는 여파는 내가 합류한 3월부터 슬슬 찾아오기 시작했다. 초반에는 비즈니스 검증 때문에 기능이 수시로 변한다. 대표가 외주에게 몇 번 변경을 요청했는데 여지없이 잔버그가 우수수 떨어지기 시작했다. 고민의 시간이 찾아왔다. 여기다가 유닛테스트를 붙일 것인가. 근데 코드를 보니 컨트롤러에 비즈니스 로직이 잔뜩 있어서 supertest로 API테스트를 붙여야만 테스트 커버리지를 올릴 수 있었다. 게다가 타입이 없는 동적언어인 자바스크립트 특성 상 커버리지를 90%정도까지는 올려야 안정적으로 돌아갈 수 있었다.

게다가 비즈니스 로직이 생각보다 복잡했다. 테이블 70개가 괜히 생긴게 아니었다. 컨테이너 종류만도 17가지 종류나 되었고 국가 간의 무역거래조건인 인코텀즈만 해도 10종류가 넘었다. 게다가 컨테이너, 인코텀즈 외에도 수 없이 많은 요소가 있었고 각 요소마다 처리하는 로직도 다 달랐기 때문에 발을 살짝만 잘못 디뎌도 순식간에 골로가는 아주 좋은(?) 조건이었다.

타입스크립트 도입

여기서의 결심은 타입스크립트 도입이었다. 그렇다고 전면 재개발은 아니었다. 나는 넷스케이프의 전철을 밟을 생각이 전혀 없었다. 다행히 개발된 코드가 ES6라서 코드 복붙 70% 정도로 API를 구성할 수 있을 것 같았고, 실제로도 그러했다. 타입스크립트 도입 시 NestJS를 비롯한 각종 타입스크립트 프레임워크를 좀 물색했는데, 최종적으로 Typescript-rest를 도입하기로 하였다. 가장 큰 이유는 나에게 주어진 시간이 많지가 않았기 때문에 바로 실전 투입할 수 있는 프레임워크여야 했다. Typescript-rest는 전 회사에서도 써서 러닝커브가 없었고 무엇보다 NestJS처럼 특정 구조를 강제하지도 않았다. NestJS가 틀에 박힌 노드판 스프링이라면 Typescript-rest는 그나마 Express에 가까운 친구였다.

소스코드를 옮기는건 힘들었지만 꽤나 재미있는 작업이었다. 보통 아래와 같이 작업을 했다.

  1. Typescript-rest에서 API 컨트롤러를 만들고 여기에 기존 소스코드를 그대로 붙인다.
  2. 물론 타입이 없기 때문에 그대로 붙지는 않는다. 한 번 붙일때마다 VSCode 화면은 공산혁명이라도 일어난듯이 온통 빨간줄이 쫙쫙 그어지곤 했다. 대부분은 타입 문제였고 일부만 코드 문제였다. 타입이 없으면 일단 any로 메꾸고 number, string, boolean 같은 Primitive 타입만 대체하는 방향으로 컨트롤러 내 API를 구성했다.
  3. API 만들때 당연히 모델도 필요했다. API 만들때마다 모델을 몇 개씩 만들기도 했다.
  4. 컨트롤러에서 비즈니스 로직을 떼어낸다. Service를 만들고 컨트롤러에 DI(Dependency Injection)했다.
  5. 최종적으로 컨트롤러에서는 Request, Response, Validation에 대한 책임만 지고, 비즈니스 로직은 서비스에서 담당하게 된다.
  6. 서비스를 상대로 유닛 테스트를 만든다.

이렇게 해서 한 달만에 거의 대부분의 소스코드를 옮겼고 커버리지도 70% 이상을 달성했다. 여기에는 우리 인턴님의 지대하신 공헌이 있었다. : >

그 외 Pub/Sub 모델 도입, Notification, Scheduling 등의 주제는 별도의 포스트를 통해서 소개하도록 하겠다.

Jest로 Express 테스트하기

Jest로 Express API 유닛 테스트를 할 때 겪었던 문제를 다른 사람들도 안 겪었으면 하는 바램에서 글을 썼다. 원래 유닛 테스트는 MochaChai를 사용했는데 이번에 한 번 Jest로 테스트 프레임워크를 바꿔보았다.

Jest를 사용하기 위해서는 일단 Install.

1
npm install --save-dev babel-cli, babel-preset-env, jest, supertest

아래는 간단한 웹 서버 코드다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// app.js
import express from 'express';

const app = express();
const port = 3000;

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

app.get('/status', (req, res) => {
res.send('ok');
});

app.listen(port);

module.exports = app;

매우 간단한 이 웹 서버는 두 개의 라우트(/hello, /status)밖에 없다. /hello 라우트는 단순하게 “world!” 문자열만 리턴하며 /status는 단순하게 “ok” 문자열만 리턴한다.

이제 Jest와 Supertest를 사용해서 이 라우트를 테스트를 해보겠다.

먼저 /hello

1
2
3
4
5
6
7
8
9
10
11
12
// tests/hello.test.js
import request from 'supertest';
import app from '../app';

describe('Test /hello', () => {
it ('should return world!', (done) => {
request(app).get('/hello').then((response) => {
expect(response.text).toBe('world!');
done();
});
});
});

Test!

깔끔하게 성공한 것을 볼 수 있다.

그럼 이제 /status에 대한 테스트를 해보자. 이 테스트는 다른 파일에 작성하도록 한다. 물론 식은 죽 먹기다.

1
2
3
4
5
6
7
8
9
10
11
12
// tests/status.test.js
import request from 'supertest';
import app from '../app';

describe('Test /status', () => {
it ('should return ok', (done) => {
request(app).get('/status').then((response) => {
expect(response.text).toBe('ok');
done();
});
});
});

Test! 🔥

😱 당연히 성공할 줄 알았던 테스트가 EADDRINUSE 에러를 내면서 실패했다. 문제는 첫 번째 테스트를 실행하면서 서버는 아직도 3000번 포트를 listen 하고 있기 때문이다. 그래서 두 번째 테스트에서 다시 import app from ‘../app’ 을 실행하면 이전 테스트에서 이미 listen하고 있는 3000번 포트를 다시 사용하기 때문에 문제가 발생한 것이었다.

이 문제를 해결하기 위해서는 테스트가 아닐 경우에만 3000번 포트를 열도록 변경해야 한다. 어차피 테스트 환경일 경우에는 Supertest를 통해서 서버를 구동하기 때문에 네트워크 포트를 열 필요가 없다. 따라서 package.json을 아래와 같이 바꾼다.

1
2
3
4
5
{
"scripts": {
"unit": "NODE_ENV=test jest tests/*.test.js --forceExit",
}
}

그리고 app.js를 아래와 같이 바꾼다.

1
2
3
4
// ...
if (process.env.NODE_ENV !== 'test') {
app.listen(port);
}

다시 한 번 실행하면

성공!

Your browser is out-of-date!

Update your browser to view this website correctly. Update my browser now

×