저희 회사의 백엔드 아키텍처
는 여러개의 마이크로 서비스가 서로 협력하여 하나의 응답을 만들어내는 구조를 가지고 있습니다.
마이크로 서비스를 호출하는 여러 개의 Client들을 만들어서 사용하고 있는데, 이 부분이 확장성
에 문제가 되어 개선한 내용을 나눠보려 합니다.
운영중인 서비스
를 개선한다는 것은 달리는 기차
의 바퀴
를 바꾸는 일처럼 매우 신중
해야 하는 작업인데요
재사용성을 높이고 확장성을 증가시킨 새로운 도전! 지금 시작
합니다!
기존 서비스
에서는 ES5
문법인 Prototype
을 이용해서 Webclient
라는 최상위 부모를 두고 다른 파일
에서 사용하는 방식으로 설계되어 있었습니다.
const Webclient = function (url) {
this.host = url;
};
Webclient.prototype = {
errHandling(err, res, body, callback) {
... error handling logic
callback(err, body);
},
handleServiceUnavailableError(res, callback) {
... error handling logic
return callback(serviceUnavailableError);
},
handleBadRequestError(res, callback) {
try {
... error handling logic
return callback(new BadRequestError());
} catch (e) {
return callback(new BadRequestError());
}
},
handleServer(res, callback) {
try {
... error handling logic
return callback(internalServerError);
} catch (e) {
return callback(new InternalServerError());
}
},
callApi(method, apiPath, params, callback) {
....some call api logic
},
get(apiPath, params, callback) {
this.callApi('GET', apiPath, params, callback);
},
async getByAsync(apiPath, params) {
const getAsync = util.promisify(this.callApi.bind(this));
return getAsync('GET', apiPath, params);
},
post(apiPath, params, callback) {
this.callApi('POST', apiPath, params, callback);
},
async postByAsync(apiPath, params) {
const postAsync = util.promisify(this.callApi.bind(this));
return postAsync('POST', apiPath, params);
},
retry(count, operation, callback) {
async.retry(count, cb => {
operation(cb);
}, callback);
},
};
prototype
은 class
로 충분히 변경할 수 있기 때문에 확장성 있는 구조를 가지게하고, 굳이 상속
받지 않아도 되는 부분도 받아야 하는 구조
를 개선
해야 했습니다.
const GuideClient = function GuideClient(host) {
WebClient.call(this, !host ? env.guide.host : host);
}
util.inherits(GuideClient, WebClient);
GuideClient.prototype.createGuide = function createGuide(params, callback) {
const apiPath = `/api/v1/guide`
const data = {
headers: {
Authorization : 'JWT TOKEN'
}
},
body: {
name: 'name',
path: 'path',
}
this.post(apiPath, data, callback);
}
... etc
이 코드의 가장 큰 문제점은 util
을 활용해서 상속관계를 맺어주지만 inherits
라는 함수를 이해하지 못하면 누가 자식
이고 누가 부모
인지 알지 못한다는 것입니다.
외부에서 들어온 데이터
를 HTTP 요청
에 맞게 만들어 주는 부분이 특정 라이브러리
에 종속된 형태
를 가지고 있다는 문제점
도 내포
하고 있습니다.
그렇다면 util 함수
를 이용해 상속 관계를 맺고 prototype
을 사용하던 코드
를 클래스
를 이용하는 것으로 변경해보겠습니다.
먼저 최상위
부모 클래스인 WebClient
를 먼저 변경해보겠습니다.
class WebClient {
_errHandling(err, res, body) {
... some error handling logic
}
handleServiceUnavailableError(res) {
... error handling logic
throw new ServiceUnavailableError();
}
async callApi(configs) {
return axios(configs)
.catch(err => this._errHandling(err, err.response, err.response.data))
.then(res => res.data);
}
async retry(configs, options) {
... some retryable-callApi
}
}
Webclient
를 상속해서 만들어진 GuideClient
를 작성해보겠습니다.
class GuideClient extends WebClient {
async createGuide(params) {
const configs = {
url: `/api/v1/guide`,
method: 'POST',
data: { name: 'name', path: 'path' },
{
headers: {
Authorization: 'JWT TOKEN'
},
}
}
return super.callApi(configs);
}
}
이렇게 코드
를 작성했을때는 여러가지 문제점
이 발생합니다.
첫 번째 문제점은 WebClient
를 사용하는 쪽에서 Configs Object
를 만드는 것이 굉장히 까다롭습니다.
HTTP 요청 클라이언트
를 사용하지만 어떤 필드
나 스펙
이 존재하는지 밖에서 알기 어렵습니다.
Configs Object
의 형태가 Axios
와 굉장히 강하게 결합되어 있어서 이 부분 또한 특정 라이브러리
에 대한 종속을 강화시킵니다.
Webclient
는 요청을 보내서 응답을 반환하는 표준 인터페이스
의 역할을 해야합니다. 따라서 Webclient
의 하위 클래스들은 굳이 클래스
일 필요가 없다고 판단했습니다.
상위 클래스
와 공유하는 것은 baseUrl
하나 뿐이므로, 이를 위해서 굳이 상속을 이용할 필요
가 없다고 보았습니다..
따라서 Webclient
는 클래스로 유지하고 나머지 하위 클래스
들은 클래스로 선언하지 않고 함수
를 직접 모듈
로 내보내기로 결정
했습니다.
class WebClient {
constructor(baseUrl) {
this.requestBuilder = new RequestBuilder();
this.requestBuilder.timeout(60000);
}
header(name, value) {
this.requestBuilder.header(name, value);
return this;
}
param(name, value) {
this.requestBuilder.param(name, value);
return this;
}
... etc
}
class RequestBuilder {
constructor(baseUrl) {
this.baseUrl = baseUrl;
this.headers = {};
this.data = {};
this.params = {};
}
header(name, value) {
this.headers = Object.assign(this.headers, { [name] : value });
}
param(name, value) {
this.params = Object.assign(this.params, { [name] : value });
}
}
WebClient
는 내부적으로 RequestBuilder
인스턴스를 가지게 하고, 각 함수는 체이닝이 가능하도록 작성해서 요청을 쉽고 직관적이게 빌드
할 수 있도록 만들었다.
const WebClient = require('.../webClient');
const webClient = new WebClient(env.guide.host);
exports.createGuide = async (params) => {
return webClient.path(`api/v1/guide`)
.method('POST')
.payload({
...
})
.callApi();
}
...
이 코드는 코드
의 사용성 자체는 좋아졌지만 문제
가 두 가지 있습니다.
첫 번째
로 webClient
의 함수를 호출하기 위해 .
으로 프로퍼티에 접근해보면 어떤 함수
부터 호출해야 할지 애매하다는 것입니다.
두 번째
로 webClient
객체는 멀티 스레드
환경에서 안전하지 못하다는 것입니다.
물론 node.js
는 싱글 스레드
기반으로 동작하지만, 멀티 스레드
로 동작도 가능하기 때문에 Thread-Safe
한 클라이언트를 만드는 것으로 목표를 잡았습니다.
webClient
라는 변수에 담긴 객체의 함수를 호출할때마다 내부의 RequestBuilder
인스턴스는 공유 자원
이 되어 요청이 처리되기 전에 다른 스레드
가 그 값을 변경하면 문제가 발생하게 됩니다.
따라서 멀티 스레드 환경
에서 안전하도록 클라이언트를 개선
할 필요가 있습니다.
Thread-Safe
하려면 사용하는 Object
가 Immutable Object
임을 보장해야 한다는 의미입니다.
그렇다면 Immutable Object
는 어떻게 만들 수 있을까?
불변성을 지키려면 모든 프로퍼티
가 불변
이거나, 특정 객체를 공유할 때 방어적 복사
를 통해서 항상 새로운 인스턴스
를 반환
함으로서 보장
할 수 있습니다.
코드 리뷰
때 나온 핵심 주제였던 "기술이 변화할때 능동적인 대응이 가능한 구조를 가진 추상화된 인터페이스 개발"에 초점을 맞춰서 표준
을 정의
할 필요가 생긴 것 입니다.
백엔드 서버
에서 외부로 HTTP
요청을 보낼 때 사용할 수 있는 표준 HttpClient
를 만드는 것이 가장 시급한 현안 과제
가 되었습니다.
node 버전
업그레이드에 의해 axios
→ fetch
로 기술이 변경되고 나중에 또 바뀔 수 있기 때문에 코드
를 사용
하는 쪽에서 변경에 취약
하면 안되기 때문입니다.
'use strict';
const RequestNonBodySpec = require('#.../requestNonBodySpec');
const RequestBodySpec = require('#.../requestBodySpec');
class HttpClient {
/**
* @constructor
*
* @param {string?} baseUrl - API의 기본 URL
* @param {object?} headers - 모든 요청에 포함하는 Default-Header
* @param {number?} apiTimeout - 요청 타임아웃 시간(밀리초)
*/
constructor(baseUrl, headers, apiTimeout) {
this.apiBaseUrl = baseUrl;
this.defaultHeaders = headers || {};
this.apiTimeout = apiTimeout || 0;
}
/**
* Create Http Client By Non-Options
*
* @return {HttpClient}
*/
static create() {
return new HttpClient();
}
/**
* Create RequestBodySpec for provided HTTP-Method
*
* @param {string} method - HTTP Method
* @return {RequestBodySpec}
*/
method(method) {
return new RequestBodySpec(method, this.apiBaseUrl, this.apiTimeout, this.defaultHeaders);
}
/**
* Create RequestNonBodySpec for 'GET' Method
*
* @return {RequestNonBodySpec}
*/
get() {
return new RequestNonBodySpec('GET', this.apiBaseUrl, this.apiTimeout, this.defaultHeaders);
}
/**
* Create RequestBodySpec for 'POST' Method
*
* @return {RequestBodySpec}
*/
post() {
return new RequestBodySpec('POST', this.apiBaseUrl, this.apiTimeout, this.defaultHeaders);
}
... etc Some HTTP Methods
/**
* Create HttpClient Builder
*
* @return {HttpClientBuilder}
*/
static builder() {
class HttpClientBuilder {
/**
* @constructer
*/
constructor() {
this.defaultHeaders = {};
}
/**
* Set BaseHeaders for HttpClientBuilder
*
* @param {object} headers
* @return {HttpClientBuilder}
*/
baseHeaders(headers) {
this.defaultHeaders = headers;
return this;
}
... etc Some Default Settings
/**
* Build & Provide HttpClient Instance
*
* @return {HttpClient}
*/
build() {
return new HttpClient(this.defaultBaseUrl, this.defaultHeaders, this.defaultTimeout);
}
}
return new HttpClientBuilder();
}
}
module.exports = HttpClient;
모든 HTTP Request
는 HttpClient
를 통해서만 요청을 전달하게 설계했습니다.
GET, DELETE와 같이 RFC Specification
에 의해 Body
를 사용하지 않는 경우에는 RequestNonBodySpec
그 외의 경우에는 RequestBodySpec
인스턴스를 새로 반환하도록 했습니다.
멀티 스레드 환경에서 HttpClient
인스턴스를 아무리 공유해도 늘 새로운 Spec
인스턴스가 반환되기 때문에 Thread-Safe
한 클라이언트를 만들 수 있었습니다.
'use strict';
const HttpConfigs = require('.../httpConfigs');
class RequestBodySpec {
/**
* @constructor
*
* @param {string} method - HTTP Method [GET, POST, PUT, PATCH, DELETE, OPTIONS]
* @param {string} baseUrl - API의 기본 URL
* @param {number} timeout - API Timeout
* @param {object} defaultHeaders - 요청 타임아웃 시간(밀리초)
*/
constructor(method, baseUrl, timeout, defaultHeaders) {
this.apiBaseUrl = baseUrl;
this.requestHeaders = { ...defaultHeaders };
this.requestMethod = method;
this.queryParams = {};
this.body = {};
this.apiTimeout = timeout;
this.isIgnoreSSL = false;
}
/**
* Setting Single Header for Request
*
* @param {string} name - 헤더 이름
* @param {string} value - 헤더 값
* @return {RequestBodySpec}
*/
header(name, value) {
this.requestHeaders = Object.assign(this.requestHeaders, { [name]: value });
return this;
}
/**
* Setting Multiple Headers for Request
*
* @param {object} headers - 헤더 객체
* @return {RequestBodySpec}
*/
headers(headers) {
this.requestHeaders = Object.assign(this.requestHeaders, headers);
return this;
}
/**
* Setting 'Accept' Header for Request
*
* @param {string} value - 허용할 Content-Type
* @return {RequestBodySpec}
*/
accept(value) {
this.header('Accept', value);
return this;
}
/**
* Setting API Timeout Value
*
* @param {number} apiTimeout - API 최대 대기 시간
* @return {RequestBodySpec}
*/
timeout(apiTimeout) {
this.apiTimeout = apiTimeout;
}
/**
* Setting Payload for Request
*
* @param {object} payload - 요청시 사용할 본문(Body)
* @return {RequestBodySpec}
*/
payload(payload) {
this.body = payload;
return this;
}
... etc some Settings
/**
* Build HttpConfigs
*
* @return {HttpConfigs}
*/
retrieve() {
return new HttpConfigs(
this.apiTimeout,
this.retryCounts,
this.retryDelays,
this.apiPath,
this.requestMethod,
this.requestHeaders,
this.queryParams,
this.isIgnoreSSL,
this.body
);
}
/**
* Build HttpConfigs & Send Http-Request (short-cut function)
*
* @return {Promise<object>}
*/
async retrieveAndSendHttpRequest() {
return this.retrieve().sendHttpRequest();
}
}
module.exports = RequestBodySpec;
HTTP 요청
을 보낼때 필요한 payload
, headers
, params
등을 설정할 수 있고, 내부적으로 API Timeout
이나 재시도 횟수와 간격등을 설정하여 여러 번 요청을 시도할 수 있습니다.
async sendHttpRequest() {
const axiosInstance = axios.create({
timeout: this.timeout,
});
// retryCount가 존재한다면, Retry Option 설정
if (this.retryCount) this._setRetryConfigs(axiosInstance);
try {
const result = await axiosInstance({
url: this.apiPath,
method: this.method,
... etc some options
});
const configs = {
url: this.apiPath,
method: this.method,
... etc some options
};
return {
status: result.status,
... etc some options
};
} catch (err) {
const { response } = err;
const configs = {
url: this.apiPath,
method: this.method,
... etc some options
logger.error(`status = [${err.status}]`);
logger.error(`information = [${JSON.stringify(err.configs, null, 2)}]`);
logger.error(`response = [${JSON.stringify(err.body, null, 2)}]`);
throw new HttpResponseError(response.status, response.data, err.message, configs);
}
}
RequestBodySpec
또는 RequestNonBodySpec
을 통해서 만들어진 옵션들은 HttpConfigs
인스턴스
로 변환되게 되며, 실질적인 HTTP Request
는 이 클래스에서 보내게 됩니다.
axios
에 종속적인 정보를 담고있는 예외
가 외부로 반환
되지 않도록 try-catch
블럭에서 직접 정의한 예외 HttpResponseError
로 변환하여 던지도록 변경했습니다.
라이브러리를 교체해도, 반환되는 응답과 에러의 표준을 정의함으로써 사용하는 쪽에서는 추상화
된 인터페이스
에만 의존하게 되는 것입니다.
class HttpResponseError extends Error {
constructor(status, body, message, configs) {
super(message);
this.status = status;
this.body = body;
this.configs = configs;
Error.captureStackTrace(this, this.constructor);
}
}
module.exports = HttpResponseError;
그렇다면 이전 코드
와 비교할 때 이 코드
의 장점
은 무엇일까요?
현재 만들어진 HTTP Client
는 method chaning
을 연속적으로 수행하며 Fluent-API
형식을 취하고 있습니다.
// 인스턴스 생성
const httpClient = require('.../httpClient').builder()
.baseUrl('/some/service/')
.baseTimeout(10000)
.build();
// 실제 사용
try {
response = await httpClient.post()
.uri('/api/v1/guide')
.accept('application/json')
.contentType('multipart/form-data')
.payload({
code,
})
.retrieveAndSendHttpRequest();
} catch (err) {
throw err;
}
사용하는 쪽에서는 이 클라이언트가 Thread-Safe
하기 위해서 어떻게 구현이 되어 있는지, 이 클라이언트가 fetch
를 사용하는지 axios
를 사용하는지 알 필요가 없게 되었습니다.
즉, 세부 구현
에 의존하지 않고 공개된 인터페이스
에 의존해 사용하면 되기 때문에 직관적이고 사용성
이 크게 향상
되었습니다.
마이크로 서비스
를 호출할 때 사용하는 클라이언트
는 특정 에러에 대해 응답하는 방식이 다르기 때문에 에러 커스텀
이 필요하고, 디폴트 에러 처리 방식도 규정되어 있습니다.
따라서 마이크로 서비스
전용 클라이언트는 따로 만들되, 실질적인 요청은 HttpClient
를 사용해 위임하는 것으로 설계했습니다.
'use strict';
const RequestSpec = require('.../requestSpec');
const httpClient = require('.../httpClient').create();
class MicroServiceClient {
/**
* @constructor
* @param {string} baseUrl
* @param {number} timeout
*/
constructor(baseUrl, timeout) {
this.apiBaseUrl = baseUrl;
this.apiTimeout = timeout || 60000;
}
/**
* MsvcClient Factory Function
*
* @param {string} baseUrl
* @param {number?} timeout
*/
static create(baseUrl, timeout) {
return new MsvcClient(baseUrl, timeout);
}
/**
* Setting HttpMethod for Request
*
* @param {string} method
* @return {RequestSpec}
*/
method(method) {
return new RequestSpec(method, this.apiBaseUrl, this.apiTimeout, this);
}
/**
*
* @return {RequestSpec}
*/
post() {
return new RequestSpec(constant.METHOD.POST, this.apiBaseUrl, this.apiTimeout, this);
}
/**
*
* @return {RequestSpec}
*/
get() {
return new RequestSpec(constant.METHOD.GET, this.apiBaseUrl, this.apiTimeout, this);
}
/**
* Validate Request-Spec has required-value
*
* @return {void}
*/
_validate(requestSpec) {
if (!requestSpec.apiPath) throw new BadRequestError('API 호출 시 경로는 필수 값입니다.');
}
/**
* Call Rest-API in MicroServices
* @return {Promise<object>}
*/
async callApi(requestSpec) {
this._validate(requestSpec);
try {
return (await httpClient.method(requestSpec.requestMethod)
.uri(requestSpec.apiPath)
.headers(requestSpec.requestHeaders)
.params(requestSpec.requestHeaders)
.payload(requestSpec.body)
.retries(requestSpec.retryCounts)
.retryDelay(requestSpec.retryDelays)
.retrieveAndSendHttpRequest()).body;
} catch (err) {
requestSpec.errorHandler.errHandling(err);
}
}
}
module.exports = MsvcClient;
4XX, 5XX
에러가 발생할때 각 마이크로 서비스
응답에 맞게 에러를 변환할 수 있도록 새로운 핸들러
를 등록하는 함수를 추가해 확장성을 높였습니다.
기본적으로 에러가 발생하면 DefaultErrorHandler
의 함수가 처리하고, 각 에러
코드 별로 에러를 처리하는 함수
를 등록하여 커스텀
이 가능하게 만들어서 유연한 확장
이 가능하도록 구현했습니다.
request.js → axios → fetch …
HTTP 요청
을 보내는 라이브러리는 지속적으로 발전해왔고 HTTP Specification
은 변화하지 않습니다.
브라우저
마다 다른 경우도 있지만, 표준 스펙
은 모든 개발자들의 공통 규약
이므로 바뀌지 않는다는 전제를 가지고 있기 때문입니다.
따라서, 사용하는 쪽에서 기술
의 변화
를 눈치채지 못하도록 표준 인터페이스
를 두고 세부 구현
을 알지 못하도록 캡슐화
하는 것이 얼마나 중요한지 알게 되었습니다.
오늘도 제 글을 읽어주셔서 감사합니다 :)