서버 개발할 때 보통 MVC패턴을 주로 사용합니다.
NestJS에서도 이와 비슷한 패턴을 사용하게 됩니다.
그런데 tRPC를 사용하게되면 이러한 @Controller라는 데코레이터를 제대로 사용할 수 없는 문제가 생기게 됩니다.
왜냐면 tRPC의 HTTP 프로토콜을 따르지만, request와 response의 구현체는 라이브러리에 모두 맡겨버리는 형태이기 때문입니다.
tRPC의 예시를 바탕으로 설명하면 아래와 같이 서버코드를 구현해두고
const t = initTRPC.create();
const router = t.router;
const publicProcedure = t.procedure;
const appRouter = router({
greeting: publicProcedure
.input(z.object({ name: z.string() }))
.query((opts) => {
const { input } = opts;
const input: {
name: string;
}
return `Hello ${input.name}` as const;
}),
});
export type AppRouter = typeof appRouter;
아래와 같은 방식으로 프론트에서 서버를 호출하는게 보통이기 때문입니다.
const trpc = createTRPCClient<AppRouter>({
links: [
httpBatchLink({
url: 'http://localhost:3000',
}),
],
});
const res = await trpc.greeting.query({ name: 'John' });
즉 @Controller데코레이터는 t.router 내부에서 사용할 수 없기도하고, t.router을 배재한다고 하면 tRPC를 사용하는 이유가 없기 때문입니다.
그래서 tRPC에 NestJS를 얹으려고 한다면 Controller 데코레이터를 포기하고 t.router를 더 잘사용하기 위해 코드를 변경해야합니다.
먼저 NestJS에서 tRPC기반의 코드를 받아줄 수 있도록 진입지점을 열어줘야합니다.
이 부분은 main.ts에서 trpc도 apply시키는 방식으로 구현해둡니다.
async function bootstrap() {
const app = await NestFactory.create(AppModule)
const trpc = app.get(TrpcRouter)
trpc.applyMiddleware(app)
await app.listen(8080)
}
bootstrap()
물론 위의 방식은 DI를 정말 잘 사용하고 있는 부분은 아니지만 부트스트래핑 해주는 부분에서 middleware를 적용시키는 의미에서 사용해줍니다.
그리고 바탕으로 한번 더 이해하면, NestJS자체의 HTTP 요청(@Controller를 사용한)도 원래 의도대로 동작하고, tRPC를 사용한 HTTP 요청도 그대로 사용할 수 있다는 걸 알고있습니다.
아래는 위에서 사용된 trpc/trpc.router의 구현체와, trpc.router를 구현하기 위한 tRPC를 init하는 trpc.service의 코드입니다.
// trpc.service
@Injectable()
export class TrpcService {
trpc = initTRPC.context<typeof this.createContext>().create()
procedure = this.trpc.procedure
router = this.trpc.router
}
// trpc.router
@Injectable()
export class TrpcRouter {
constructor(
private readonly trpcService: TrpcService,
private readonly userController: UserController,
private readonly anohterController: AnotherController,
) {}
appRouter = this.trpcService.router({
user: this.userController.router,
another: this.anohterController.router,
})
async applyMiddleware(app: INestApplication) {
app.use(
`/trpc`,
trpcExpress.createExpressMiddleware({
router: this.appRouter,
createContext: this.trpcService.createContext as any,
onError(opts) {
const { error, type, path, input, ctx, req } = opts
console.error('Error:', error)
},
}),
)
}
}
export type AppRouter = TrpcRouter['appRouter']
위의 코드를 보면 applyMiddleware를 통해 tRPC의 구현체를 사용할 수 있는데 이를 위해서는 router를 사용해야합니다.
Controller의 역할이 결국 routing해주는 역할이기도 하고, tRPC에서도 말하고자 하는 바가 이와 동일하기 때문에, tRPC아래서 NestJS의 Controller는 tRPC의 Router를 만들어주는 역할이 돼야합니다.
이 과정에서 Auth를 다루는 코드나, 더 좋은 타입 추론을 위한 코드를 추가할 수 있는데 이는 다음에 한번 더 글을 작성해보겠습니다.
@Injectable()
export class UserController {
constructor(
private readonly trpcService: TrpcService,
) {}
router = this.trpcService.router({
hello: this.trpcService.procedure
.input(
z.object({
name: z.string().optional(),
}),
)
.query(({ input }) => {
const { name } = input
return {
greeting: `Hello ${name ? name : `Bongsu`}`,
}
}),
})
}
그래서 tRPC아래의 NestJS에서의 Controller는 위와 같은 코드가 나오게 됩니다.
router를 정의해주는데 이는 trpcService.router를 통해 열어줍니다.
이때 사용한 trpcService.router는 trpc.router에서 사용한 것과 동일하게 사용되는데, 프론트에서 더 편하게 api를 사용할 수 있도록 매핑시켜주기 위한 과정으로 사용됩니다. 즉 용도와 필요에 따라 trpcService.router로 뎁스를 만들어줄 수 있습니다.
Controller이후의 Service나 Repository는 이전에 사용하는 것과 동일하게 사용할 수 있는데, Controller는 위와 같이 정의해야 tRPC에 맞게 사용할 수 있습니다.
이렇게 코드를 구현하면
const trpc = createTRPCClient<AppRouter>({
links: [
httpBatchLink({
url: 'http://localhost:3000',
}),
],
});
const res = await trpc.user.hello.query({ name: 'hello tRPC' });
처럼 사용할 수 있게 됩니다.
위에서 trpcService.router를 중복해서 사용한 덕분에 user.hello로 관심사에 맞는 api를 나눠줄 수 있고, 이를 통해 기존의 Controller와 유사한 효과를 낼 수 있습니다.
뿐만 아니라 나중에 tRPC를 버리고 일반 HTTP 혹은 다른 프로토콜로 전환할 때도 Controller의 코드만 변환하여 사용할 수 있습니다.