요새 Next.js만으로 대규모 백엔드 API 서버를 구현하고 운영할 수 있는지에 대한 실험을 하고 있습니다. 애플리케이션에 필요한 모든 인프라를 Serverless 환경으로 구성해서 인프라를 관리하는 비용을 극단적으로 줄이고 Next.js 하나만으로 프론트엔드와 백엔드를 통합해 조직의 생산성을 높일 수 있는지...
이미 기술적으로는 충분히 가능하다고 보이지만, Spring Boot와 AWS를 메인으로 사용하던 조직에 변화를 만들려면 기존 기술 스택의 만족스러운 점들을 최대한 잃지 않으면서도 새로운 기술 스택이 가져오는 장점이 충분히 크다는 것을 보여야 합니다.
백엔드의 비즈니스 로직을 IoC(Inversion of Control) 컨테이너로 관리하는 것도 그 중 하나입니다. Spring을 통해 유지하던 백엔드 비즈니스 로직의 낮은 복잡도를 Next.js를 사용하면서도 그대로 유지할 수 있을지 탐구중이고, 그 과정에서 Node.js 생태게의 IoC 컨테이너중 하나인 inversify의 타입 안전성을 강화할 수 있는 inversify-typesafe 라이브러리를 작성해서 소개글을 적습니다.
2014년에 AWS Lambda가 등장하면서 Serverless 컴퓨팅이 산업적으로 주목받기 시작했습니다. 2018년에는 Amazon Aurora Serverless v1이 릴리즈되었고, 2021년 말에는 PlanetScale처럼 개발자 경험을 극대화한 Serverless RDBMS(Relational Database Management System)가 등장해서 커뮤니티의 관심을 받았습니다.
Next.js는 등장부터 풀 스택 리액트 프레임워크로 주목을 받았고, 이제는 다양한 Serverless Edge Hosting 업체들(Vercel, Cloudflare, Netlify 등)을 통해서 풀 스택 리액트 애플리케이션을 간단하게 배포할 수 있는 세상이 되었습니다.
Serverless RDBMS와 Serverless Edge Hosting은 이미 상용 제품에 활발이 사용되고 있습니다. 이 둘을 함께 활용하면 전형적인 웹 애플리케이션의 인프라에 필요한 노력을 극단적으로 줄일 수 있습니다. 예를 들어, 별도의 백엔드 API 서버 없이 Next.js의 백엔드에서 Serverless DB에 직접 커넥션을 맺고 데이터를 가져오는 것은 물론 Next.js의 백엔드를 그저 BFF(Backend for Frontend)가 아니라 백엔드 API를 제공하기 위한 프레임워크로도 사용할 수 있습니다.
만약 Next.js를 대규모 백엔드를 위한 프레임워크로 사용한다면, 코드량에 따라 기하급수로 올라가는 시스템의 복잡도를 통제할 도구를 도입해야 합니다. 객체지향 세상에선 이미 IoC(Inversion of Control) 컨테이너가 복잡도를 통제하기 위한 표준적인 도구로 사용되고 있고 Node.js 생태계에도 NestJS나 InversifyJS같은 훌륭한 IoC 컨테이너 라이브러리가 존재합니다
웹 관련 기능은 Next.js가 담당할 것이기 때문에 이 상황에서는 다른 부가 기능 없이 단순히 IoC 기능만 제공할 수 있는 컨테이너만 있으면 됩니다. NestJS에서 웹 부분을 제외하고 DI 기능만 쓸 수 있지만(https://docs.nestjs.com/faq/serverless#using-standalone-application-feature), 번들 크기가 inversify보다 3배 무겁고 (inversify: 57.9kB, @nestjs/core: 189.2kB) 제게 필요했던 기능이 Inversify만으로 충분히 제공되기 때문에 Next.js로 구성한 백엔드의 복잡도를 통제하기 위한 도구로 inversify를 골라 이것저것 실험을 해보고 있습니다.
Inversify는 그 자체로 이미 훌륭하지만, 사용해보니 타입 선언을 조금만 개선하면 타입스크립트의 기능을 활용해 타입-안전하게 의존성 주입을 할 수 있을 것 같아서 라이브러리를 작성했습니다: inversify-typesafe
아래는 inversify가 제공하는 Dependency Inversion 예제입니다.
import { Container, inject, injectable, ServiceIdentifier } from 'inversify';
interface Weapon {
damage: number;
}
// symbol은 Node.js의 원시타입중 하나. 이 예제에서는 문자열과 유사하다고 생각해도 충분하다.
const ninjaServiceId: ServiceIdentifier<Ninja> = Symbol.for('NinjaServiceId');
const weaponServiceId: ServiceIdentifier<Weapon> = Symbol.for('WeaponServiceId');
@injectable()
class Katana implements Weapon {
public readonly damage: number = 10;
}
@injectable()
class Ninja {
constructor(
// JVM에서는 런타임에도 타입 정보가 살아있으므로, 어떤 인터페이스의 구현체가 인스턴스가 하나만 등록되어 있다면
// 컴포넌트의 인스턴스를 주입받기 위해 따로 이름을 입력하지 않아도 인스턴스 주입이 가능하다.
// 그러나 타입스크립트는 런타임에 타입 정보가 사라지므로 어떤 인터페이스의 구현체 인스턴스가 하나만
// 등록되어 있더라도 주입받을 인스턴스에 대한 이름(혹은 ID)을 필수로 입력해야 한다.
// 이 예제에서는 런타임에 Weapon 인스턴스의 타입 정보를 사용할 수 없으므로 weaponServiceId라는 값을 사용한다.
@inject(weaponServiceId)
public readonly weapon: Weapon, // 구현체 대신 인터페이스를 의존 (Dependency Inversion)
) {}
}
const container: Container = new Container();
// 서비스 ID와 구현체를 매핑
container.bind(ninjaServiceId).to(Ninja);
container.bind(weaponServiceId).to(Katana);
// 특정 bean을 요청했을 때 인스턴스를 생성해서 제공한다 (lazy).
const ninja: Ninja = container.get(ninjaServiceId);
console.log(ninja.weapon.damage);
(Demo: https://stackblitz.com/edit/inversify-di)
이대로도 나쁘지 않지만, 타입스크립트를 더 잘 활용하면 더 나은 코드를 작성할 수 있지 않을까요?
예를 들어
- 컨테이너에서 서비스를 조회할 때 서비스 ID만 입력해도 자동으로 타입이 추론된다면?
- 컨테이너에 등록되지 않은 서비스 ID를 입력했을 때 컴파일 에러가 발생한다면?
- 서비스를 주입받을 때 등록되지 않은 서비스 ID를 입력하면 컴파일 에러가 발생한다면?
Inversify 컨테이너의 타입-안전성을 강화하기 위해 간단한 라이브러리를 작성했습니다: inversify-typesafe
실행 가능한 Demo: https://stackblitz.com/edit/inversify-typesafe
import { createTypesafeContainer, returnTypesafeInject, TypesafeServiceConfig } from "inversify-typesafe";
// https://inversify.io/docs/introduction/dependency-inversion/
interface Weapon {
damage: number;
}
class Katana implements Weapon {
public readonly damage: number = 10;
}
export const typesafeInject = returnTypesafeInject<Services>()
class Ninja {
constructor(
// decorator의 parameter가 존재하지 않는 서비스 ID면 컴파일에러 발생
@typesafeInject("weaponServiceId")
public readonly weapon: Weapon,
) { }
}
export type Services = {
"ninjaServiceId": Ninja; // class
"weaponServiceId": Weapon; // interface
};
export const serviceConfig: TypesafeServiceConfig<Services> = {
// 'Ninja'와 호환되지 않는 서비스를 바인딩하려고 하면 컴파일 에러 발생
"ninjaServiceId": (bind) => bind().to(Ninja),
// 'Weapon'과 호환되지 않는 서비스를 바인딩하려고 하면 컴파일 에러 발생
// 두 번째 파라미터인 _container를 활용해서 복잡한 바인딩을 수행할 수 있다
"weaponServiceId": (bind, _container) => bind().to(Katana),
};
// 컨테이너를 생성할 때 타입 정보를 따로 입력하지 않아도 추론해서 사용한다
const typesafeContainer = createTypesafeContainer(serviceConfig);
// 서비스 ID를 입력하면 자동으로 타입 추론을 한다.
console.log(typesafeContainer.get("ninjaServiceId").weapon.damage);
inversify-typesafe를 구현하면서 다음과 같은 점들을 고려했습니다:
- 라이브러리는 타입-안전성을 위해 특정한 서비스 등록 방식을 강제하지만, InversifyJS의 기능을 제한해서는 안 된다.
- 라이브러리 사용자는 원할 때 언제든지 InversifyJS의 모든 기능을 사용할 수 있어야 한다.
- 위 두 가지 원칙을 지키면서 사용자의 실수를 가능한 한 컴파일 타임에 잡아야 한다.
- 라이브러리 사용자가 선언해야 하는 타입은 최소화해야 한다.
- 컨테이너에 문자열 서비스 ID를 입력하면 추가적인 타입 작성 없이 등록된 타입을 자동으로 추론합니다.
- 서비스를 찾기 위해
get메서드에 문자열을 입력할 때, 코드 편집기의 자동 완성을 통해 등록된 서비스 ID를 확인할 수 있습니다. - 컨테이너에 등록되지 않은 서비스 ID를 입력하면 컴파일 에러가 발생합니다.
- 서비스를 주입할 때 등록되지 않은 서비스 ID를 입력하면 컴파일 에러가 발생합니다.
- 추가적인 peer depepdency가 없습니다. 오직
inversify와reflect-metadata만 있으면 됩니다. - 100% 테스트 커버리지.
export type Services = {
"ninjaServiceId": Ninja; // class
"weaponServiceId": Weapon; // interface
};
사용자는 컨테이너에 등록될 서비스를 선언하기 위해 Services(또는 원하는 이름) 맵 타입을 작성해야 합니다. Services 타입의 키는 서비스 ID로 사용됩니다. InversifyJS는 다양한 타입(class, symbol 등)을 서비스 ID로 등록하지만, inversify-typesafe는 오직 string 타입만을 서비스 ID로 사용합니다. string을 사용함으로써 TypeScript의 String Literal Types 기능을 활용하여 마법 같은 타입 안전성을 제공할 수 있습니다.
import { TypesafeServiceConfig } from "inversify-typesafe";
export const serviceConfig: TypesafeServiceConfig<Services> = {
"ninjaServiceId": (bind) => bind().to(Ninja),
"weaponServiceId": (bind, _container) => bind().to(Katana),
};
이 라이브러리는 TypesafeServiceConfig<T>라는 유틸리티 타입을 제공합니다. TypesafeServiceConfig<T>는 타입 T의 키를 서비스 ID로 사용하도록 강제합니다. 앞서 선언한 Services 타입을 TypesafeServiceConfig의 타입 매개변수로 전달하면 Services 타입의 키만 사용할 수 있도록 제한됩니다. Services에 존재하지 않는 키를 입력하거나 Services의 모든 키에 대한 함수를 제공하지 않으면 컴파일 에러가 발생하여 사용자가 더 안전하게 코드를 작성할 수 있게 합니다.
객체의 값은 사용자가 Inversify의 모든 바인딩 기능을 활용할 수 있도록 람다를 사용합니다. 람다는 첫 번째 매개변수로 bind, 두 번째 매개변수로 container를 받습니다. 첫 번째 매개변수는 서비스 ID를 컨테이너에 바인딩하기 위한 thunk () => container.bind(serviceId)입니다. 객체의 키를 서비스 ID로 바인딩하는 thunk가 매개변수로 전달되므로, 사용자는 서비스 ID에 서비스를 어떻게 매핑할지 선택할 수 있습니다. 사용자가 Services 타입 선언과 호환되지 않는 서비스를 매핑하려고 하면 컴파일 에러가 발생합니다.
람다의 두 번째 매개변수는 container를 받습니다. 간단한 경우에는 첫 번째 매개변수 bind만 사용해도 충분하지만, 서비스 등록 과정에서 컨테이너에 직접 접근해야 하는 경우 두 번째 매개변수를 활용할 수 있습니다.
import { createTypesafeContainer } from "inversify-typesafe";
const typesafeContainer = createTypesafeContainer(serviceConfig);
createTypesafeContainer() 함수는 TypesafeServiceConfig<T> 타입의 인수를 받아 TypesafeContainer<T>를 반환합니다. 사용자는 제네릭 타입 매개변수를 수동으로 지정할 필요가 없습니다.
const ninjaService = typesafeContainer.get("ninjaServiceId");
사용자가 선언한 Services 타입의 키를 인수로 전달하면 바인딩된 서비스가 반환됩니다. 반환 타입은 추가적인 타입 작성 없이 잘 추론되며, 코드 편집기의 자동 완성을 통해 등록된 서비스 ID를 확인할 수 있습니다.
Services의 키가 아닌 값을 입력하면 컴파일 에러가 발생합니다.
import { returnTypesafeInject } from "inversify-typesafe";
export const typesafeInject = returnTypesafeInject<Services>()
class Ninja {
constructor(
@typesafeInject("weaponServiceId")
public readonly weapon: Weapon,
) { }
}
사용자가 선언한 Services 타입을 타입 매개변수로 하여 고계 함수 returnTypesafeInject<T>()를 호출하면 데코레이터 함수가 반환됩니다. 이 데코레이터의 매개변수에 Services 타입의 키가 아닌 값을 입력하면 컴파일 에러가 발생합니다.
import { ServiceIdentifier } from "inversify";
const ninjaService = typesafeContainer._get("ninjaServiceId" as ServiceIdentifier<Ninja>);
TypesafeContainer<T> 타입의 _get 메서드는 기존 Container 타입의 get 메서드와 동일한 기능을 수행합니다. 타입 안전성이 강화된 기능이 아닌 원래의 get 메서드가 필요할 때 _get 메서드를 사용할 수 있습니다.
get 메서드를 제외한 모든 메서드는 InversifyJS Container 타입과 동일합니다.
InversifyJS의 scope 기본값은 Request입니다. 컨테이너의 get 함수를 호출할 때마다 새로운 객체를 생성합니다.
웹 백엔드 환경에서는 특별한 경우가 아니라면 한 번 생성한 인스턴스를 재활용하므로 기본 scope 값을 Singleton으로 사용하는게 보통이고, Spring의 IoC 컨테이너의 기본 scope도 Singleton입니다.
inversify-typesafe-spring-like 라이브러리는 inversify-typesafe 라이브러리를 확장해서 Spring Framework와 유사한 DX(Developer Experience)를 제공하는 간단한 라이브러리입니다.
라이브러리의 API는 Spring의 용어를 반영하여 설계했습니다:
createTypesafeContext()->ApplicationContext()- Note:
defaultScope는 기본적으로Singleton으로 설정됩니다.
- Note:
returnTypesafeInject()->returnAutowired()TypesafeServiceConfig<T>->BeanConfig<T>
기본 scope와 용어 변경 외에는 inversify-typesafe와 동일합니다.
실행 가능한 Demo: https://stackblitz.com/edit/inversify-typesafe-spring-like
import { ApplicationContext, BeanConfig, returnAutowired } from "inversify-typesafe-spring-like";
interface Article {
id: number;
title: string;
content: string;
}
interface ArticleOutgoingPort {
getById(id: number): Promise<Article>
}
class ArticleRepository implements ArticleOutgoingPort {
getById(id: number): Promise<Article> {
return Promise.resolve({
id: id,
title: `title #${id}`,
content: `content #${id}`,
})
}
}
interface GetArticleUseCase {
execute(id: number): Promise<Article>
}
const { Autowired } = returnAutowired<Beans>();
class ArticleQueryService implements GetArticleUseCase {
constructor(
@Autowired("ArticleOutgoingPort") // compile error if a parameter of @Autowired is not a key of Beans.
private readonly articleOutgoingPort: ArticleOutgoingPort,
) { }
execute(id: number): Promise<Article> {
return this.articleOutgoingPort.getById(id);
}
}
type Beans = {
GetArticleUseCase: GetArticleUseCase; // interface (class is also possible)
ArticleOutgoingPort: ArticleOutgoingPort; // interface (class is also possible)
}
const beanConfig: BeanConfig<Beans> = {
// compile error if ArticleQueryService is not compatible with GetArticleUseCase.
GetArticleUseCase: (bind) => bind().to(ArticleQueryService),
// compile error if ArticleRepository is not compatible with ArticleOutgoingPort.
ArticleOutgoingPort: (bind) => bind().to(ArticleRepository),
}
const applicationContext = ApplicationContext(beanConfig);
const getArticleUseCase = applicationContext.get("GetArticleUseCase")
getArticleUseCase.execute(1).then(console.log)
지금 보고 있는 블로그의 소스 코드도 inversify-typesafe-spring-like를 사용합니다: https://github.com/myeongjae-kim/terrace/blob/3832db22be8dd72955e5510fe0eb34baf9d2d254/src/app/config/BeanConfig.ts
Next.js를 풀스택 프레임워크로 사용하면서 백엔드 로직을 객체지향적으로 관리하고 싶을 때 타입 안전성까지 챙기고 싶다면 한 번 맛 보세요.
실무에서도 이 라이브러리를 활용중인데, 적당히 큰 프로젝트에 적용해보면 후기 글을 작성하겠습니다.