1편의 연장선으로, 실제 기술 면접의 코드 리뷰에서 mysql2에 의존적인 코드를 어떻게 변화에 유연하게 만들것이냐는 질문을 받았다.
레이어간 역할을 명확하게 하는데 그치지 않고 데이터베이스에 엑세스하는 레이어를 두 단계로 나눠서, 레포지토리 레이어는 단순 쿼리 실행 레이어로 인프라 레이어를 데이터베이스와 연결하는 레이어로 두어 인프라 레이어만 교체하게끔 할 수 있다고 생각했다.
ORM, DB 라이브러리와 비슷하게 실무에서 많이 쓰는 외부 의존성이 무엇이 있을까 생각해보다가, 실제로 운영중인 서비스에서 많이 활용중인 Nest의 HttpModule을 바탕으로 의존성을 제어해보고자 했다. 현재 운영중인 서비스의 HTTP 클라이언트 라이브러리 사용 구간은 전환이 이루어지지 않은 일부를 비롯하여 샌드버드, 웹 훅, SNS 회원가입 시 약관 정보를 가져오기 위해 사용하는 등 여러 구간에서 애플리케이션의 핵심적인 기능을 담당하고 있다.
기존 모듈의 한계
위 코드는 공식 문서의 예제, 아래 코드는 실 사용중인 샌드버드 API의 일부이다.
공식 문서에 따르면 AxiosResponse는 axios에서 내보낸 인터페이스이며, 모든 HttpService 메서드들은 Observable로 래핑된 AxiosResponse를 반환한다고 한다.
export class HttpService {
constructor(
@Inject(AXIOS_INSTANCE_TOKEN)
protected readonly instance: AxiosInstance = Axios,
) {}
get<T = any, D = any>(
url: string,
config?: AxiosRequestConfig<D>,
): Observable<AxiosResponse<T, D>> {
return this.makeObservable<T>(this.instance.get, url, config);
}
get axiosRef(): AxiosInstance {
return this.instance;
}
protected makeObservable<T>(
axios: (...args: any[]) => AxiosPromise<T>,
...args: any[]
) {
return new Observable<AxiosResponse<T>>(subscriber => {
const argsCopy = [...args];
const configIdx = argsCopy.length - 1;
const config: AxiosRequestConfig = { ...(argsCopy[configIdx] || {}) };
argsCopy[configIdx] = config;
let cancelSource: CancelTokenSource;
if (!config.cancelToken) {
cancelSource = Axios.CancelToken.source();
config.cancelToken = cancelSource.token;
}
axios(...argsCopy)
.then(res => {
subscriber.next(res);
subscriber.complete();
})
.catch(err => {
subscriber.error(err);
});
return () => {
if (config.responseType === 'stream') {
return;
}
if (cancelSource) {
cancelSource.cancel();
}
};
});
}
}
실제로 소스를 들여다보면, get 메서드는 Axios Promise -> Observable을 반환하게 되어있다.
현 상황에서 Observable의 이점을 활용할 수 없다는 판단이 섰다. 유연한 처리(요청 취소, 체이닝 등)를 할 필요가 없는 상황. 단순 응답 데이터를 받아 그대로 활용하는 상황에서 굳이? 라는 생각을 했다.
역시나 현명하신 프레임워크 개발 팀(?)에서 다이렉트로 Axios Response를 활용할 수 있게 공식문서에 예제를 남겨주셨다.
"아! 그럼 axiosRef를 사용하면 되는구나~"
하고 개발을 완료했었는데, 최근 인프랩 면접을 기점으로 코드를 보는 시각이 조금 달라졌다.
항상 구현에서 그치지 않고 유연한 설계를 지향하기 시작했다.
axiosRef는 axios 그 자체이다. AxiosInstance는 axios를 상속받은 구현체이다.
그럼 나는 불필요하게 axios를 사용하기 위해서 특정 프레임워크에 종속되어 있는건가? 왜 굳이 HttpService를 사용해야하지?
라는 생각들이 마구 들어 직접 HTTP 클라이언트 라이브러리를 활용하여 HttpService를 구현해보고자 했다.
추가로 axios에서 fetch로, got, undici로 HTTP 클라이언트 라이브러리가 교체되더라도 유연한 구조를 설계해보고 싶었다.
독립적인 HTTP 모듈 구성
우선 생각한 구조는 다음과 같다.
HTTP 메서드들을 추상화한 인터페이스를 활용해서, HttpService에서 이를 상속받아 인터페이스의 메서드들을 구현하고 이 서비스에서 직접적으로 HTTP 클라이언트 라이브러리에 의존하기 때문에, 라이브러리가 교체되더라도 HttpService만 교체하면 되는 상황을 설계하고자 했다.
// http-client.interface.ts
export interface IHttpClient {
get<T>(url: string, config?: HttpConfig): Promise<HttpResponse<T>>;
post<T>(url: string, data: any, config?: HttpConfig): Promise<HttpResponse<T>>;
//...put, patch, delete, ......
}
import { Injectable } from '@nestjs/common';
import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios';
import { IHttpClient, IHttpRequestConfig, IHttpResponse } from './interface/http.interface';
@Injectable()
export class AxiosHttpService implements IHttpClient {
private readonly instance: AxiosInstance;
constructor(config: IHttpRequestConfig) {
this.instance = axios.create(this.transformConfig(config));
}
async get<T = any>(url: string, config?: IHttpRequestConfig): Promise<IHttpResponse<T>> {
const axiosResponse = await this.instance.get<T>(url, this.transformConfig(config));
return this.transformResponse(axiosResponse);
}
async post<T = any>(url: string, data?: any, config?: IHttpRequestConfig): Promise<IHttpResponse<T>> {
const axiosResponse = await this.instance.post<T>(url, data, this.transformConfig(config));
return this.transformResponse(axiosResponse);
}
//...put, patch, delete, ......
private transformConfig(config?: IHttpRequestConfig): AxiosRequestConfig {
return { ...config };
}
private transformResponse<T>(axiosResponse: AxiosResponse<T>): IHttpResponse<T> {
return {
data: axiosResponse.data,
status: axiosResponse.status,
headers: axiosResponse.headers as Record<string, string>,
};
}
}
// http-client.types.ts
export interface IHttpRequestConfig {
baseUrl?: string;
timeout?: number;
headers?: Record<string, string>;
params?: Record<string, any>;
}
export interface HttpResponse<T = any> {
data: T;
status: number;
headers: Record<string, string>;
}
생각나는대로 바로 구현했던 코드들이다.
위에서 말한 것 처럼 인터페이스 클라이언트에서 메서드들을 정의하고, HttpService에서 이를 구현했다. Axios를 사용하니 AxiosHttpService로 지었다.
요청에 필요한 Config을 직접 정의하고, 응답 객체 또한 직접 만들어 해당 라이브러리에 종속되지 않도록 했다.
(요청 Config는 각 라이브러리들 마다 포맷이 있을 것 같아 아래에서 실제 라이브러리에 의존하도록 교체해주었다.)
// http.module.ts
@Global()
@Module({
providers: [
{
provide: 'HTTP_CLIENT',
useFactory: () => {
return new AxiosHttpService({
timeout: 5000,
});
}
}
],
exports: ['HTTP_CLIENT']
})
export class HttpModule {}
constructor(
@Inject('HTTP_CLIENT')
private readonly axiosHttpService: IHttpClient,
){}
async getChannels(webId: string): Promise<IHttpResponse<any>> {
const params = webId
? {
members_include_in: webId,
show_member: true,
limit: 100,
}
: {
show_member: true,
limit: 100,
};
return this.axiosHttpService.get(`${this.BASE_URL}/group_channels`, {
params,
headers: {
'Content-Type': 'application/json',
'Api-Token': this.API_TOKEN,
},
});
}
이제 Nest의 주입 포맷에 맞게, 주입해서 사용해주면 된다. 원하는 대로 설계는 잘 이루어졌고 제대로 동작했다.
관련해서 레퍼런스들을 찾다가, 올해 코딩적 영감을 가장 많이 받은 인프랩의 기술 블로그에 같은 주제의 포스팅이 있었다.
포스팅을 서너번 읽어봤는데 코드의 가독성이 훨씬 더 좋고, 내부 서버에서 사용할 클래스들로 유연하게 변경시켜주는 차이점이 있었다.
나는 위 설계를 바탕으로 코드를 작성한지 2시간 반 만에 리팩토링을 해야만 했다.
아니 반드시 하고 싶었다.
리팩토링
응답 데이터로 유연하게 객체 생성하기
우선, 응답받은 데이터를 바로 내부에서 사용하는 객체로 변환시켜주고자 했다.
처음엔 단순 class-transformer의 plainToInstance만을 사용하려고 했으나, 응답받는 데이터가 Snake Case이거나, 불규칙적인 형태일 경우 모두 고려해야했고, 현 상황에서 모두를 아우를 수 있는 형태로 발전하게 되었다.
(class-transformer는 class-validator와 더불어 시너지를 내기도 좋고, nest라는 프레임워크를 사용하는 이유 중 하나라고 생각을 하기 때문에, 적극 사용중이다.)
import { ClassConstructor, plainToInstance } from 'class-transformer';
export class HttpResponseFactory {
/**
* HTTP response data를 엔티티로 변환
* @param path - 데이터를 가져올 경로 (e.g. "channels")
*/
static toEntities<T>(entityClass: ClassConstructor<T>, plainObject: any, path: string): T[] {
const nestedData = path.split('.').reduce((obj, key) => obj?.[key], plainObject);
// 경로의 데이터가 배열이 아니라면 배열로 감싸 단일 객체도 처리
const dataArray = Array.isArray(nestedData) ? nestedData : [nestedData];
return plainToInstance(entityClass, dataArray, {
enableImplicitConversion: true,
excludeExtraneousValues: true,
});
}
}
/**
* HTTP response
*/
export class HttpResponse {
constructor(
private readonly data: any,
private readonly statusCode: number,
) {}
/**
* HTTP response data를 엔티티로 변환
*/
toEntities<T>(entityClass: ClassConstructor<T>, path: string): T[] {
return HttpResponseFactory.toEntities(entityClass, this.data, path);
}
getData(): any {
return this.data;
}
getStatusCode(): number {
return this.statusCode;
}
}
HttpResponse에서 사용할 객체 변환 팩토리를 하나 구성했고, HttpResponse도 알맞게 변경해주었다.
카멜케이스로의 전환은 class-transformer의 @Expose로, 불규칙적인 데이터를 확인하여 변환해주는 작업은 @Type으로 처리하였다.
//channel.dto.ts
import { Expose, Type } from 'class-transformer';
import { ChannelMemberState } from '../enum/enum';
/**
* 채널 생성자
*/
export class ChannelCreatedBy {
@Expose({ name: 'user_id' })
userId!: string;
@Expose({ name: 'nickname' })
nickname!: string;
@Expose({ name: 'profile_url' })
profileUrl!: string;
}
/**
* 채널에 있는 멤버들의 정보
*/
export class ChannelMembers {
@Expose({ name: 'user_id' })
userId!: string;
@Expose({ name: 'nickname' })
nickname!: string;
@Expose({ name: 'profile_url' })
profileUrl!: string;
@Expose({ name: 'state' })
state!: ChannelMemberState;
@Expose({ name: 'is_active' })
isActive!: boolean;
@Expose({ name: 'is_online' })
isOnline!: boolean;
}
/**
* 채널 정보
*/
export class ChannelResponse {
@Expose({ name: 'channel_url' })
channelUrl!: string;
@Expose({ name: 'cover_url' })
coverUrl!: string;
@Expose()
name!: string;
@Expose({ name: 'created_at' })
createdAt!: number;
@Expose({ name: 'member_count' })
memberCount!: number;
@Type(() => ChannelCreatedBy)
@Expose({ name: 'created_by' })
createdBy!: ChannelCreatedBy;
@Type(() => ChannelMembers)
@Expose()
members!: ChannelMembers[];
}
이제, 위 DTO를 활용해서, 샌드버드 채널 정보를 가져오는 수많은 데이터를 입맛에 맞게 내부적으로 변환하여 사용할 수 있게 되었다.
메서드 체이닝
async get<T = any>(url: string, config?: IHttpRequestConfig): Promise<IHttpResponse<T>>
{
const axiosResponse = await this.instance.get<T>(url, this.transformConfig(config));
return this.transformResponse(axiosResponse);
}
기존에는 실제 axios와 똑같이, get메서드 하나에 모든 파라미터를 다 집어넣어서 데이터를 뽑아내는 방식이었다.
취향 차이일지 모르겠으나 내 기준에서는 위 기술 블로그의 메서드 체이닝 방식이 훨씬 읽기 편하고, 원하는 메서드만 골라서 사용할 수 있어 훨씬 유연할 것 처럼 보였다.
import axios, { AxiosRequestConfig, AxiosResponse } from 'axios';
import { HttpBodyFactory, MediaType } from './factory/request-body.factory';
import { HttpResponse, IHttpClient } from './interface/http.interface';
export class AxiosHttpService implements IHttpClient {
private readonly _config: AxiosRequestConfig = {};
constructor(baseConfig: AxiosRequestConfig) {
this._config = baseConfig;
}
uri(uri: string): this {
this._config.url = uri;
return this;
}
header(headers: Record<string, string>): this {
this._config.headers = { ...this._config.headers, ...headers };
return this;
}
contentType(mediaType: MediaType): this {
this._config.headers = { ...this._config.headers, 'Content-Type': mediaType };
return this;
}
query(params: Record<string, any>): this {
this._config.params = { ...this._config.params, ...params };
return this;
}
body<T>(body: HttpBodyFactory<T>): this {
this._config.data = body.getData();
this.contentType(body.getMediaType());
return this;
}
get(): this {
this._config.method = 'get';
return this;
}
post(): this {
this._config.method = 'post';
return this;
}
put(): this {
this._config.method = 'put';
return this;
}
patch(): this {
this._config.method = 'patch';
return this;
}
delete(): this {
this._config.method = 'delete';
return this;
}
head(): this {
this._config.method = 'head';
return this;
}
options(): this {
this._config.method = 'options';
return this;
}
async fetch<T = any>(): Promise<HttpResponse> {
const axiosResponse = await axios.create(this._config).request<T>({
...this._config,
});
return this.transformResponse(axiosResponse);
}
private transformResponse<T>(axiosResponse: AxiosResponse<T>): HttpResponse {
return new HttpResponse(axiosResponse.data, axiosResponse.status);
}
}
//http-body.factory.ts
export enum MediaType {
APPLICATION_JSON = 'application/json',
APPLICATION_FORM_URLENCODED = 'application/x-www-form-urlencoded',
TEXT_PLAIN = 'text/plain',
}
export class HttpBodyFactory<T> {
private constructor(
private readonly _mediaType: MediaType,
private readonly _data: T,
) {}
static fromJSON(json: Record<string, unknown>) {
return new HttpBodyFactory(MediaType.APPLICATION_JSON, json);
}
static fromFormData(form: Record<string, unknown>) {
return new HttpBodyFactory(MediaType.APPLICATION_FORM_URLENCODED, form);
}
static fromText(text: string | Buffer) {
return new HttpBodyFactory(MediaType.TEXT_PLAIN, text);
}
getMediaType(): MediaType {
return this._mediaType;
}
getData(): T {
return this._data;
}
}
메서드 체이닝을 사용할 수 있게, 기존의 인터페이스와 더불어 HttpService를 변경하고, 필요한 구간들도 다 정리해주었다.
기존에 body를 넣는 구간이 없이 모두 한 번에 사용했으니 Body를 생성하는 팩토리도 만들어 사용하게 되었다.
@Injectable()
export class SendbirdService {
private readonly APP_ID = process.env.SENDBIRD_APP_ID!;
private readonly API_TOKEN = process.env.SENDBIRD_API_TOKEN!;
private BASE_URL: string;
constructor(
@Inject('HTTP_CLIENT')
private readonly httpService: IHttpClient,
) {
this.BASE_URL = `https://api-${this.APP_ID}.sendbird.com/v3`;
}
async getChannels(webId: string): Promise<ChannelResponse[]> {
const params = webId
? {
members_include_in: webId,
show_member: true,
limit: 100,
}
: {
show_member: true,
limit: 100,
};
return this.httpService
.uri(`${this.BASE_URL}/group_channels`)
.header({
'Content-Type': MediaType.APPLICATION_JSON,
'Api-Token': this.API_TOKEN,
})
.query(params)
.get()
.fetch()
.then((response) => {
return response.toEntities(ChannelResponse, 'channels');
});
}
}
최종 형태의 SendbirdService가 완성되었다. 위 코드에서, 어디에도 axios에 의존적인 구간은 없는 것으로 보인다. 설계한 의도대로 잘 흘러간 것 같다.
정리
HTTP 클라이언트 라이브러리를 사용하기 위해 추가적인 프레임워크의 특정 모듈에 의존적이어야 하는 상황.
이 상황을 너무 종속적이고 유연하지 못하다고 판단했고, 실제 구현한 HttpService 구현체를 통해 다른 라이브러리로의 교체가 이루어지더라도 해당 서비스만 교체하면 될 것 처럼 설계했다.
라이브러리를 교체해도 모듈 내부만 교체하면 된다. 외부에서는 교체됐는지 알 필요도, 알 수도(???) 없다.
추가로, 인터페이스 자체를 모킹해서 테스트에도 용이할 것이다.
유연한지 확인하기 위한 라이브러리 교체
import fetch, { RequestInit, Response } from 'node-fetch';
import { HttpBodyFactory, MediaType } from './factory/request-body.factory';
import { HttpResponse, IHttpClient } from './interface/http.interface';
export class FetchHttpService implements IHttpClient {
private _config: RequestInit = {};
private _url: string = '';
constructor(baseConfig: RequestInit) {
this._config = { ...baseConfig };
}
uri(uri: string): this {
this._url = uri;
return this;
}
header(headers: Record<string, string>): this {
this._config.headers = { ...this._config.headers, ...headers };
return this;
}
contentType(mediaType: MediaType): this {
this._config.headers = { ...this._config.headers, 'Content-Type': mediaType };
return this;
}
query(params: Record<string, any>): this {
const query = new URLSearchParams(params).toString();
const url = new URL(this._url);
url.search = query;
this._url = url.toString();
return this;
}
body<T>(body: HttpBodyFactory<T>): this {
this._config.body = JSON.stringify(body.getData());
this.contentType(body.getMediaType());
return this;
}
get(): this {
this._config.method = 'GET';
return this;
}
post(): this {
this._config.method = 'POST';
return this;
}
put(): this {
this._config.method = 'PUT';
return this;
}
patch(): this {
this._config.method = 'PATCH';
return this;
}
delete(): this {
this._config.method = 'DELETE';
return this;
}
head(): this {
this._config.method = 'HEAD';
return this;
}
options(): this {
this._config.method = 'OPTIONS';
return this;
}
async fetch(): Promise<HttpResponse> {
const response = await fetch(this._url, this._config);
return this.transformResponse(response);
}
private async transformResponse(response: Response): Promise<HttpResponse> {
const data = await response.json();
return new HttpResponse(data, response.status);
}
}
// http.module.ts
@Global()
@Module({
providers: [
{
provide: 'HTTP_CLIENT',
useFactory: () => {
return new FetchHttpService({
timeout: 5000,
});
}
}
],
exports: ['HTTP_CLIENT']
})
export class HttpModule {}
아무것도 건들지 않고, axios만 제거한 뒤 node-fetch를 설치했다. (config에 url이 세팅되지 않아서 url은 따로 필드화하여 사용했다.)
위처럼, 설계한 HTTP Module만 갈아끼워버리면 끝이다.
서비스 레이어에는 아무 변경사항 없이, 라이브러리 교체로 인한 라이브러리에 의존적인 모듈 내부만 교체하여, 외부에서는 교체됐는지도 알 수 없는, 변화에 유연한 설계가 완성되었다.
참조
https://docs.nestjs.com/techniques/http-module
https://github.com/nestjs/axios
https://github.com/axios/axios
https://tech.inflab.com/20230723-pure-http-client/
2023.04 ~ 백엔드 개발자의 기록
포스팅이 좋았다면 "좋아요❤️" 또는 "구독👍🏻" 해주세요!