변화에 유연한 HTTP 클라이언트 만들기
안녕하세요. 인프랩 백엔드 개발자 인트입니다.
현대의 웹 애플리케이션은 API를 통해 다양한 데이터와 서비스를 연동하는 것이 일상이 되었습니다.
이러한 API를 사용하기 위해 다양한 통신방법이 존재하는데, HTTP를 가장 보편적으로 사용합니다.
우리는 이러한 HTTP 통신을 위해 다양한 외부 라이브러리를 사용하고 있습니다.
이번 포스트에는 Nest.js 환경에서 외부 라이브러리에 종속되지 않고 쉽게 테스트할 수 있는 HTTP 클라이언트를 만든 경험을 공유하고자 합니다.
인터페이스 분리, 추상화 등 지금은 당연하게 사용하는 개념들에 대한 이야기가 될 것 같습니다.
후반부에는 더 나아가 선언적 HTTP 클라이언트에 대한 이야기와 이를 구현한 라이브러리를 소개하고자 합니다.
Nest.js HTTP Module의 단점
본격적인 내용에 앞서 Nest.js에서 제공하는 HTTP Module
에 대한 이야기를 나누고자 합니다.
Nest.js를 사용하신다면 이 모듈의 도입을 한 번쯤 고민해 보셨을 것입니다.
공식 문서를 보면 다음과 같은 예제가 있습니다.
@Injectable()
export class CatsService {
constructor(private readonly httpService: HttpService) {
}
findAll(): Observable<AxiosResponse<Cat[]>> {
return this.httpService.get('http://localhost:3000/cats');
}
}
짧은 코드이지만 이 모듈의 사용법과 제공하는 인터페이스를 파악할 수 있습니다.
저희도 이 모듈의 적용을 고려해 봤지만, 다음과 같은 이유로 사용하지 않았습니다.
Observable을 반환
HTTPModule
이 제공하는 HttpService
의 모든 HTTP 요청 메서드는 Observable을 반환합니다.
Observable은 여러 비동기 작업이 발생하는 상황에서 rxjs
에서 제공하는 연산자를 활용해 쉽게 체이닝을 할 수 있는 장점이 있습니다.
하지만 저희는 대부분 일회성 HTTP 요청을 받아 처리하는 경우가 많았기에 Promise를 반환하는 것이 더 유용하다고 생각합니다.
아니면 공식 문서에서 안내하는 Promise를 반환하는 방법을 사용할 수 있지만, 이는
내부적으로 Promise -> Observable -> Promise
로 변환하는 불필요한 과정이 추가됩니다.
AxiosResponse를 반환
HttpService
가 Observable을 반환하는 것을 넘어 내부에 AxiosResponse를 반환하는 것도 문제였습니다.
AxiosResponse는 axios 라이브러리에서 제공하는 타입으로, HttpService에 의존하는 서비스는 결국 axios에 의존하게 됩니다.
Node.js
를 오랜 기간 사용하신 분들은 request라는 라이브러리를 사용해 보셨을 것입니다.
한때 커뮤니티에서 가장 인기 있는 HTTP 클라이언트 라이브러리였고 널리 사용되었습니다.
하지만 callback api의 한계로 지금은 완전히 deprecated된 상태이며 axios, got, node-fetch 등 다양한 라이브러리를 사용하고 있습니다.
사실상 표준이라고 할 만한 HTTP 클라이언트 라이브러리는 없는 상황에서, axios도 언젠가 deprecated될 가능성이 있습니다.
만약 이러한 일이 발생해 새로운 라이브러리로 교체하는 상황이라고 가정해봅시다.
HttpService
의 내부 로직만 새로운 라이브러리로 교체하면 쉽게 해결될 거 같은데?
라는 생각이 들 수도 있지만, 생각만큼 쉽지 않을 수 있습니다.
먼저 HttpService
에 의존한 서비스는 높은 확률로 AxiosResponse를 import 하고 있을 것이기에 이를 모두 새로운 라이브러리가 반환하는 항목으로 교체해야 합니다.
물론 텍스트 치환 기능을 사용해 쉽게 교체할 수 있지만, 서비스 내에서 AxiosResponse에만 존재하는 속성을 사용하고 있다면 이를 수정하기란 쉽지 않을 것입니다.
결국 기존 로직이 제대로 동작함을 보장하기 위해 새로운 응답으로 반환하는 것을 포기하고, 새로운 응답을 AxiosResponse로 변환하는 과정을 추가하는 상황이 발생할 수도 있습니다.
애초에 rxjs
, axios
등 우리가 제어할 수 없는 외부 라이브러리의 상황에 따라 서비스 코드도 함께 수정해야 하는 상황이 문제라고 생각합니다.
이는 OCP라 불리는 개방 폐쇄 원칙을 지키지 못한 설계라고 할 수 있습니다.
WebClientModule 구조
위와 같은 이유로 저희는 WebClientModule
이라 불리는 HTTP 클라이언트 모듈을 직접 만들어 사용하고 있습니다.
이 모듈의 역할은 내부 구현은 감추고 핵심적인 HTTP 인터페이스만 노출해 외부에서 사용할 수 있도록 하는 것입니다.
먼저 모듈의 구성요소와 관계를 보여드리겠습니다.
외부에서 의존하는 두 개의 인터페이스가 있고 구현체도 존재합니다.
또한 HTTP 요청과 응답을 다루기 위한 두 개의 클래스가 존재합니다.
이제 각 구성요소에 대한 자세한 내용을 살펴보겠습니다.
이해를 돕기 위해 실제 운영코드와 다르거나 간소화한 부분이 있는 점 양해 부탁드립니다.
WebClient
export interface WebClient {
get(): this;
head(): this;
post(): this;
put(): this;
patch(): this;
delete(): this;
options(): this;
uri(uri: string): this;
header(param: Record<string, string>): this;
contentType(mediaType: MediaType): this;
body<T>(inserter: BodyInserter<T>): this;
retrieve(): Promise<ResponseSpec>;
}
외부 서비스가 의존하는 인터페이스로 GET
, POST
등의 HTTP 메서드를 제공하고 header
, body
등 HTTP 요청에 필요한 정보를 설정할 수 있습니다.
builder 패턴을 적용해 각 메서드가 this
를 반환하며 retrieve
메서드를 호출하면 HTTP 요청을 수행하며 ResponseSpec
을 반환합니다.
이 인터페이스의 네이밍과 스펙은 Spring WebCleint 을 참고하였습니다.
이로 인해 builder 패턴을 적용하였지만, 선호에 따라 axios의 api 스펙을 참고하셔도 좋을 것 같습니다.
BodyInserter
BodyInserter
는 contentType에 따라 다른 형태의 body를 생성하기 위한 클래스입니다.
WebClient
의 contentType
을 직접 호출하지 않고 BodyInserter
를 통해 자동으로 설정할 수 있습니다.
또한 생성자 메서드를 통해 어떠한 형태의 body를 생성할지 표현할 수 있다는 장점이 있습니다.
export class BodyInserter<T> {
private constructor(
private readonly _mediaType: MediaType,
private readonly _data: T,
) {
}
static fromJSON(json: Record<string, unknown>) {
return new BodyInserter(MediaType.APPLICATION_JSON, json);
}
static fromFormData(form: Record<string, unknown>) {
return new BodyInserter(MediaType.APPLICATION_FORM_URLENCODED, form);
}
static fromText(text: string | Buffer) {
return new BodyInserter(MediaType.TEXT_PLAIN, text);
}
// ...생략
}
ResponseSpec
ResponseSpec
은 HTTP 요청에 대한 응답을 표현하는 클래스입니다.
HTTP 응답에 항상 존재하는 상태 코드와 응답 바디를 제공하며, 추가로 toEntity
메서드를 통해 응답을 특정 클래스의 인스턴스로 변환할 수 있습니다.
export class ResponseSpec {
constructor(
private readonly _statusCode: number,
private readonly _body: string,
) {
}
toEntity<T>(entity: ClassConstructor<T>): T {
return plainToInstance(entity, JSON.parse(this._body));
}
get statusCode() {
return this._statusCode;
}
get rawBody(): string {
return this._body;
}
}
응답 객체를 클래스의 인스턴스로 만드는 경우 메서드를 활용해 응답 값들을 처리하는 로직을 넣을 수 있습니다.
또한 외부 api 응답 프로퍼티 명이 불분명하거나 내부 컨벤션에 맞지 않는 경우에도 이를 원하는 이름으로 쉽게 변환할 수 있습니다.
import {Expose} from 'class-transformer';
export class UserApiResponse {
@Expose({name: 'user_id'})
userId: string;
@Expose({name: 'usr_eml'})
userEmail: number;
get emailDomain() {
return this.userEmail.split('@')[1];
}
}
ResponseSpec는 class-transformer 라이브러리에 의존하기에 결국 응답객체도 이 라이브러리에 의존하지 않나요?
맞습니다. class-transformer 또한 외부 라이브러리이기에 기존에 언급한 문제가 발생할 수 있습니다.
이를 해결하려면 ResponseSpec
또한 인터페이스로 추상화하고, Expose
를 감싼 커스텀 데코레이터를 만드는 방안이 있습니다.
하지만 class-transformer의 교체 가능성과 예상 작업 공수, 그리고 이미 수많은 컨트롤러의 요청, 응답 객체에서 라이브러리에 의존하고 있다는 점을 고려해 이를 적용하지 않았습니다.
GotClient
WebClient
의 구현체로 got 라이브러리를 사용하고 있습니다.
상황에 따라 axios, node-fetch 등 다른 라이브러리를 사용할 수 있습니다.
import got, {ExtendOptions} from 'got';
export class GotClient implements WebClient {
private static readonly TIMEOUT = 10_000;
private readonly _option: ExtendOptions;
constructor(url?: string, timeout = GotClient.TIMEOUT) {
this._option = {
method: 'GET',
url: url,
timeout: {
request: timeout,
response: timeout,
},
};
}
uri(uri: string): this {
this._option.url = uri;
return this;
}
get(): this {
this._option.method = 'GET';
return this;
}
post(): this {
this._option.method = 'POST';
return this;
}
async retrieve(): Promise<ResponseSpec> {
const response = await got({
...this._option,
isStream: false,
resolveBodyOnly: false,
responseType: 'text',
});
return new ResponseSpec(response.statusCode, response.body);
}
// ...생략
}
WebClientService
WebClient
는 builder 패턴을 적용했기에 다른 HTTP 요청을 위해 새로운 인스턴스를 생성해야 합니다.
이를 위해 WebClientService
클래스를 만들었으며 WebClient
인스턴스를 생성하는 역할을 합니다.
이는 TypeORM
의 DataSource
가 createQueryBuilder
를 통해 QueryBuilder
인스턴스를 생성하는 것과 유사합니다.
create
메서드는 선택적으로 url을 받는데 WebClient
의 uri
메서드를 호출하는 것과 동일한 효과를 가집니다.
abstract class WebClientService {
abstract create(url?: string): WebClient;
}
export class GotClientService extends WebClientService {
override create(url?: string): WebClient {
return new GotClient(url);
}
}
이번에는 왜 WebClientService를 추상 클래스로 선언했나요?
WebClientService
를 Nest.js의 provider로 등록하기 위함입니다.
만약 인터페이스로 선언한 경우 빌드 과정에서 사라지기에 정상적으로 등록되지 않아 의존성을 찾을 수 없다는 에러가 발생합니다.
인터페이스로 선언하길 원한다면 직접 커스텀 토큰을 만들고 @Inject
데코레이터를 통해 주입받아야 합니다.
이제 이 서비스를 외부에서 사용할 수 있게 하는 모듈이 바로 WebClientModule
입니다.
@Global()
@Module({
providers: [{
provide: WebClientService,
useClass: GotClientService,
}],
exports: [WebClientService],
})
export class WebClientModule {
}
사용 예시
이제 내부 서비스에서 WebClientService
를 사용하는 샘플 코드를 살펴보겠습니다.
import {Injectable} from '@nestjs/common';
import {BodyInserter, WebClientService} from '@app/web-client';
@Injectable()
export class UserService {
constructor(private readonly webClientService: WebClientService) {
}
async create(email: string, password: string): Promise<void> {
const response = await this.webClientService
.create(`https://user.domain.com/users`)
.post()
.body(BodyInserter.fromJSON({email, password}))
.retrieve()
.then((spec) => spec.toEntity(UserApiResponse));
// 그 외 로직 수행하기
}
}
UserService
는 WebClientService
를 주입받아 WebClient
인스턴스를 생성하고, post
메서드를 통해 HTTP 요청을 수행합니다.
import 구문을 보면 이 서비스는 got
라이브러리에 의존하지 않는 것을 확인할 수 있습니다.
지금까지 모듈의 각 요소에 대해 살펴보았습니다.
앞서 보여드렸던 각 요소의 관계를 다시 살펴보면 이해가 더 쉬울 것 같습니다.
WebClientModule 장점
대부분 알고 계시겠지만, 위와 같은 설계를 통해 다음과 같은 효과를 얻을 수 있습니다.
- 유닛 테스트 작성이 용이합니다.
- 외부 라이브러리 교체가 쉽습니다.
각 항목을 예제 코드를 통해 자세히 살펴보겠습니다.
순수한 유닛 테스트 작성
Nest.js에서 제공하는 HttpService를 사용해 로직을 작성했다고 가정해 봅시다.
이러한 로직을 실제 서비스에 요청을 수행하지 않고 테스트하려면 다음과 같은 방법을 생각해 볼 수 있습니다.
- 가짜 서버를 띄워 해당 서버에 요청을 수행
- HttpService의 내부 메서드를 mock으로 대체
- nock과 같은 라이브러리를 사용해 http 요청을 가로채기
위와 같은 방법은 테스트 코드를 위해 필요한 또 다른 외부 라이브러리가 필요하게 됩니다.
테스트 코드도 운영 코드처럼 지속적인 유지 보수가 필요하기에, 외부 라이브러리의 교체가 필요한 경우 대량의 테스트 코드도 함께 수정해야 하는 상황이 발생할 수 있습니다.
따라서 테스트 코드도 외부 라이브러리에 의존하지 않는 순수한 코드로 작성하는 게 좋다고 생각합니다.
이는 개인적인 견해이며, 외부 라이브러리가 제공하는 편의성을 무시할 수는 없다고 생각합니다.
만약 jest를 사용하지 않고 테스트 코드를 작성해야 한다고 생각하면 매우 끔찍한 경험이 될 거 같습니다.
먼저 WebClient
의 구현체인 MockWebClient
를 만들어보겠습니다.
필요한 구현메서드 중 일부는 생략했습니다.
export class MockWebClient implements WebClient {
// builder 메서드를 통해 전달받은 인자들을 저장하기 위한 필드
urls: string[] = [];
methods: Method[] = [];
requestBodies: string[];
responses: {
statusCode: number;
body: string;
}[];
get(): this {
this.methods.push('GET');
return this;
}
uri(url: string): this {
this.urls.push(url);
return this;
}
async retrieve(): Promise<ResponseSpec> {
const response = this.#responses.shift();
if (!response) {
throw new Error('설정된 응답값이 없습니다');
}
return new ResponseSpec(response.statusCode, response.body);
}
body<T>(inserter: BodyInserter<T>): this {
this.requestBodies.push(inserter.data);
return this;
}
// 각 테스트 케이스 실행 전에 필요한 초기화 작업
clear(): this {
this.urls = [];
this.methods = [];
this.requestBodies = [];
this.responses = [];
return this;
}
// 실제 요청을 수행하지 않고 미리 준비한 응답을 설정하기 위한 메서드
addResponse(
statusCode: HttpStatus,
body: string,
): this {
this.#responses.push({statusCode, body});
return this;
}
}
WebClientService
도 마찬가지로 테스트를 위한 구현체를 만들어보겠습니다.
export class MockWebClientService extends WebClientService {
constructor(private readonly mockWebClient: MockWebClient) {
super();
}
override create(url?: string): WebClient {
return this.mockWebClient;
}
}
두 클래스의 구현은 간단하지만 jest의 mock을 사용해 자주 사용하는 다음 기능을 똑같이 지원합니다.
- 미리 준비한 응답을 설정
- 중간 과정에서 전달받은 인자들을 추적
- 각 테스트 케이스 실행 전에 필요한 초기화 작업
이제 이를 활용한 테스트 코드는 다음과 비슷한 형태가 될 것입니다.
describe('UserService', () => {
const mockWebClient = new MockWebClient();
const mockWebClientService = new MockWebClientService(mockWebClient);
const userService = new UserService(mockWebClientService);
beforeEach(() => {
mockWebClient.clear();
});
it('success', async () => {
// given
mockWebClient.addResponse(HttpStatus.CREATED, '{"userId": 100}');
// when
await userService.create()
// then
expect(mockWebClient.urls[0]).toBe('https://user.domain.com/users');
expect(mockWebClient.methods[0]).toBe('POST');
// 그 외 검증
})
})
위 코드는 descirbe, it과 같은 jest의 기본 기능만 사용했으며, 특히 Nest.js에서 제공하는 @nestjs/testing
패키지조차 사용하지 않았습니다.
이는 미래에 jest 외에 mocha, vitest와 같은 다른 테스팅 프레임워크로 교체하는 비용을 줄일 수 있습니다.
유연한 외부 라이브러리 교체
지금까지 계속 이야기한 내용이지만, 인터페이스의 구현체만 교체하면 외부 라이브러리를 쉽게 변경할 수 있습니다.
예를 들면 got
라이브러리 대신 node-fetch
로 교체하는 상황을 가정해 보겠습니다.
이 경우 두 인터페이스를 구현한 파일이 새로 추가되며 수정이 필요한 파일은 오직 WebClientModule
뿐입니다.
// FetchClient.ts
export class FetchClient implements WebClient {
// fetch를 사용해 메서드 구현
}
// FetchClientService.ts
export class FetchClientService extends WebClientService {
override create(url?: string): WebClient {
return new FetchClient(url);
}
}
// WebClientModule.ts
@Global()
@Module({
providers: [{
provide: WebClientService,
useClass: FetchClientService, // 수정이 필요한 유일한 부분
}],
exports: [WebClientService],
})
export class WebClientModule {
}
선언적 HTTP 클라이언트
지금까지 WebClientModule
을 통해 HTTP 클라이언트를 만드는 방법을 소개했습니다.
이번에는 현재 구조를 더 개선하기 위한 방안을 소개하고자 합니다.
앞서 소개한 UserService
코드를 다시 살펴보겠습니다.
export class UserService {
constructor(private readonly webClientService: WebClientService) {
}
async create(email: string, password: string): Promise<void> {
const response = await this.webClientService
.create(`https://user.domain.com/users`)
.post()
.body(BodyInserter.fromJSON({email, password}))
.retrieve()
.then((spec) => spec.toEntity(UserApiResponse));
// 그 외 로직 수행하기
}
}
사실 UserService는 필요한 로직을 수행하기 위해 UserApiResponse가 필요할 뿐, 이를 위해 특정 서버로 HTTP 요청을 해야 하는 것은 주요 관심사가 아닙니다.
하지만 WebClientService
에 사용하는 이상 서비스에는 HTTP에 의존하는 코드를 포함하고 있습니다.
만약 통신방법을 grpc로 변경한다면 이 서비스도 함께 수정해야 합니다.
우리는 보통 이런 외부 인프라에 의존하는 코드를 서비스 로직과 분리해서 작성해왔습니다.
가령 데이터베이스에 의존하는 코드는 Repository
라는 이름을 가진 클래스로 분리하고, 이를 서비스에서 주입받아 사용했습니다.
HTTP 클라이언트도 동일한 방식으로 분리할 수 있습니다.
@Injectable()
export class UserRepository {
constructor(private readonly webClientService: WebClientService) {
}
async create(email: string, password: string): Promise<UserApiResponse> {
return this.webClientService
.create(`https://user.domain.com/users`)
.post()
.body(BodyInserter.fromJSON({email, password}))
.retrieve()
.then((spec) => spec.toEntity(UserApiResponse));
}
}
이렇게 분리해도 UserService에 대한 유닛 테스트가 용이해지는 장점이 있습니다.
하지만 수많은 api에 대해 매번 이렇게 분리하는 것은 번거로운 일이 될 수 있습니다.
JVM 진영에서는 이러한 문제를 개선한 라이브러리들이 존재합니다.
OpenFeign, Retrofit 등이 있으며 최근 Spring 6.0에서도 HTTP Interface라는 기능이 추가되었습니다.
이 라이브러리들은 HTTP 요청에 대한 로직을 직접 작성하지 않고 인터페이스에 어떤 데이터가 필요한지만 작성하면, 이를 바탕으로 HTTP 요청을 수행하는 구현체를 자동으로 생성해 줍니다.
마치 SQL처럼 데이터를 가져오는 로직을 구체적으로 작성하지 않고, 단지 “이러한 조건을 만족하는 데이터를 조회해”라고만 작성하면 DBMS가 이를 수행해 주는 것과 유사합니다.
이러한 방식을 보통 선언적(Declarative)이라고 표현하며 위와 같은 라이브러리를 선언적 HTTP 클라이언트라고 부르기도 합니다.
@HttpExchange(url = "/repos/{owner}/{repo}", accept = "application/vnd.github.v3+json")
interface RepositoryService {
@GetExchange
Repository getRepository(@PathVariable String owner, @PathVariable String repo);
@PatchExchange(contentType = MediaType.APPLICATION_FORM_URLENCODED_VALUE)
void updateRepository(@PathVariable String owner, @PathVariable String repo,
@RequestParam String name, @RequestParam String description, @RequestParam String homepage);
}
Nest.js 용 선언적 HTTP 클라이언트
사실 위와 같은 개념을 처음 접했을 때, Nest.js에서도 이를 구현할 수 있지 않을까 생각했습니다.
TypeScript에도 데코레이터가 존재하며, Nest.js의 DiscoveryService
를 통해 런타임에 인터페이스의 구현체를 생성할 수 있기 때문입니다.
하지만 TypeScript의 인터페이스는 런타임에 존재하지 않고 데코레이터를 적용할 수 없기에 클래스를 사용해야 하는 단점이 존재합니다.
그래서 다음과 같은 코드 형태를 생각해 봤습니다.
@HttpInterface('https://example.com/api') // base url
class UserHttpService {
@GetExchange('/users/{id}') // path
@ResponseBody(UserResponse) // 응답 dto
async request(@PathVariable('id') id: number): Promise<UserResponse> {
return {} as any; // 타입 에러를 방지하기 위한 코드
}
}
위와 같은 클래스를 provider로 등록하면 자동으로 구현부를 생성해 주는 방식입니다.
기존 WebClientModule
의 BodyInserter
와 ResponseSpec
와 같은 클래스는 @ResponseBody
와 같은 데코레이터로 대체할 수 있습니다.
내부 서비스는 오직 WebClientService
, WebClient
와 같은 인터페이스가 아닌 오직 직접 작성한 인터페이스(UserHttpService)에만 의존하게 됩니다.
하지만 UserHttpService를 클래스로 선언했기 때문에 각 메서드의 구현부를 필수로 작성해야 하며, 타입 에러를 방지하기 위해 return {} as any
와 같은 코드를 작성해야 하는 아쉬움은 존재합니다.
하지만 HTTP 요청에 대한 로직을 직접 작성하는 방식보다는 간결하기에 충분히 사용할 만한 방식이라고 생각합니다.
위와 같은 기능을 적용한 라이브러리를 만들었는데 관심 있으시면 살펴보셔도 좋을 것 같습니다.
nest-http-interface