추상화 추상화 추상화!! - HttpClient 개선기

DevSeoRex·2024년 9월 7일
1

😁 들어가며,,

저희 회사의 백엔드 아키텍처는 여러개의 마이크로 서비스가 서로 협력하여 하나의 응답을 만들어내는 구조를 가지고 있습니다.

마이크로 서비스를 호출하는 여러 개의 Client들을 만들어서 사용하고 있는데, 이 부분이 확장성에 문제가 되어 개선한 내용을 나눠보려 합니다.

운영중인 서비스를 개선한다는 것은 달리는 기차바퀴를 바꾸는 일처럼 매우 신중해야 하는 작업인데요

재사용성을 높이고 확장성을 증가시킨 새로운 도전! 지금 시작합니다!

😡 누가 Prototype 소리를 내었어?

기존 서비스에서는 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);
    },
};

prototypeclass로 충분히 변경할 수 있기 때문에 확장성 있는 구조를 가지게하고, 굳이 상속받지 않아도 되는 부분도 받아야 하는 구조개선해야 했습니다.

🙇🏻 클래스가 없어도 이건 너무하잖아!

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 할건데?

Thread-Safe 하려면 사용하는 ObjectImmutable Object임을 보장해야 한다는 의미입니다.

그렇다면 Immutable Object는 어떻게 만들 수 있을까?

불변성을 지키려면 모든 프로퍼티불변이거나, 특정 객체를 공유할 때 방어적 복사를 통해서 항상 새로운 인스턴스반환함으로서 보장할 수 있습니다.

코드 리뷰때 나온 핵심 주제였던 "기술이 변화할때 능동적인 대응이 가능한 구조를 가진 추상화된 인터페이스 개발"에 초점을 맞춰서 표준정의할 필요가 생긴 것 입니다.

😎 우리팀의 표준 내가 만든다!

백엔드 서버에서 외부로 HTTP 요청을 보낼 때 사용할 수 있는 표준 HttpClient를 만드는 것이 가장 시급한 현안 과제가 되었습니다.

node 버전 업그레이드에 의해 axiosfetch로 기술이 변경되고 나중에 또 바뀔 수 있기 때문에 코드사용하는 쪽에서 변경에 취약하면 안되기 때문입니다.

'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 RequestHttpClient를 통해서만 요청을 전달하게 설계했습니다.

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 Clientmethod 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를 사용하는지 알 필요가 없게 되었습니다.

즉, 세부 구현에 의존하지 않고 공개된 인터페이스에 의존해 사용하면 되기 때문에 직관적이고 사용성이 크게 향상 되었습니다.

🙄 MicroService 전용 클라이언트

마이크로 서비스를 호출할 때 사용하는 클라이언트는 특정 에러에 대해 응답하는 방식이 다르기 때문에 에러 커스텀이 필요하고, 디폴트 에러 처리 방식도 규정되어 있습니다.

따라서 마이크로 서비스 전용 클라이언트는 따로 만들되, 실질적인 요청은 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.jsaxiosfetch

HTTP 요청을 보내는 라이브러리는 지속적으로 발전해왔고 HTTP Specification은 변화하지 않습니다.

브라우저마다 다른 경우도 있지만, 표준 스펙은 모든 개발자들의 공통 규약이므로 바뀌지 않는다는 전제를 가지고 있기 때문입니다.

따라서, 사용하는 쪽에서 기술변화를 눈치채지 못하도록 표준 인터페이스를 두고 세부 구현을 알지 못하도록 캡슐화 하는 것이 얼마나 중요한지 알게 되었습니다.

오늘도 제 글을 읽어주셔서 감사합니다 :)

🙇🏻 

0개의 댓글