ECMAScript | TypeScript

tRPC와 함께하는 Next.js 백엔드 개발

partner_jun 2023. 3. 2. 18:30

 

 

회사에서도, 개인적으로도 최근 진행한 프로젝트는 모두 Next.js를 사용하고 있다. 퍼블리싱을 맡는 부서가 나누어져 있지 않고 SEO와 관련된 문제를 해결하기 위한 것이 크지만, 한편으로는 리액트 진영의 라이브러리들로 편하게 개발하고자 하는 면도 있다. 하지만 Next.js를 사용해도 백엔드의 컨트롤러 부분, 그러니까 클라이언트의 요청을 직접 받는 부분을 개발하기 위한 해답이 명확하지 않다. 작은 규모의 프로젝트에서 서버간 통신을 구현할 때에는 Axios를 이용한 레이어링으로 충분하다. 하지만 API의 개수와 규모가 커지다 보면 Next.js에서 지원하는 api 폴더 내에 코드 뭉치를 욱여넣는 것으로는 부족해진다. 특히 API 엔드포인트들을 관리하고 타입 체크를 하는 것 자체가 엄청난 피로감을 유발한다. 이를 해결하고자 나온 것 중 하나가 tRPC다. 혁신적인 라이브러리라며고 소개할 정도는 아니라는 생각이 들지만 최근 몇달간 사용해본 결과 충분히 추천할만한 라이브러리라는 생각이 들어 소개해보고자 한다.

 

 

 

1. tRPC란 무엇인가?

tRPC라는 프로젝트 이름에서 추측 가능하듯 RPC(원격 프로시저 호출)과 비슷한 이름으로 개발되었다(구글의 gRPC와 유사한 네이밍으로 보인다). RPC라는 것은 간단하게 말해 다른 공간에 있는 '실행 가능한 것'을 실행하는 것으로 프론트엔드에서 백엔드의 무엇인가를 실행한다는 점에서 이름을 따온게 아닐까 한다. 기능별로 잘게 찢어 프로젝트로 파생시키는 추세에 맞는 네이밍이라고도 볼 수 있을 것 같다.

나는 가장 빠르게 이 라이브러리를 설명하는 방법이 'GraphQL의 귀찮음을 Typescript로 덜어낸 것' 이라고 생각한다.

홈페이지에서도 직접 GraphQL을 언급하고 있다

GraphQL과 다른 점이라면 GraphQL에서 분노를 유발하는 것 중 하나인 스키마 생성을 Typescript 기반의 인터페이스(혹은 타입)으로 대체하면서 추가적인 빌드나 코딩 과정이 필요 없어지고, 요즘 들어 자주 보이는 zod를 이용하여 요청 파라미터의 타입들을 체크한다는 점이다. 특히 라이브러리 자체가 Typescript의 제네릭을 이용해 만들어졌기에 클라이언트 사이드와 백엔드 사이드 양 쪽에서 같은 방법으로 요청하거나 응답을 받을 수 있다. 한번 코드를 작성하면 프론트엔드와 백엔드 양 쪽에서 코드 자동 완성이 가능한 객체를 이용해 호출하거나 요청을 받을 수 있게 되고, 이것이 전체적인 개발 생산성을 끌어 올리는데 큰 역할을 한다.

 

 

 

2. 다시한번, TRPC란 무엇인가?

최근(이라기엔 오래되었지만)의 FE 개발 추세를 되짚어 보며 다시 설명해볼까 한다.

기기의 성능이 향상되고 웹 사이트가 복잡해지면서 사용자에게 보여지는 페이지, 그러니까 프론트엔드에 요구되는 스펙은 복잡해졌으며, 빠른 개발을 요구받게 되었다. 그에 따라 FE 개발자들은 말 그대로 FE에 집중할 수 있을 것이라 기대했고, 각종 교육 시설에서도 그렇게 가르치는 것으로 안다. 그렇게 이상적인 환경은 아래 그림과 같을 것이다.

이상적인(?) 프론트엔드-백엔드 개발

하지만 실제로 서비스를 운영해보면 기대처럼 진행할 수 없다. 기술적인 문제는 당연히 가지고 있으니 제외하고, 크게 두가지 측면에서 문제가 있다. 첫번째는 책임과 관리의 문제다. 많은 서비스들이 MFA(Micro Frontend Architecture) 형식으로 넘어가면서 운영되는 도메인이(서버가) 늘어나고 있고, 상황에 따라 스케일링이 필요한데 무거운 백엔드를 붙여 스케일링을 할 수는 없다. 두번째는 개발 사이클 측면이다. 상대적으로 느린 BE의 배포 주기에 FE의 빠른 사이클은 맞지 않는다. 특히 유지보수 단계로 들어갔을 때 가장 큰 어긋남이 생긴다. 성능상, 혹은 디자인 변경 등 다양한 이유로 인해 FE 배포가 잦아지면 BE 입장에서는 의미없는 배포가 있을 수밖에 없다. 결국 FE를 위한 서버가 분리되고, FE를 위한 API 게이트웨이 서버 - Backend For Frontend, BFF 서버를 구성하게 된다. 문제는 여기서부터다. BFF서버로 비즈니스 로직을 분리한 것은 좋은데 그 서버에 대한 요청과, 사용자 인증 정보 처리 등 앞 단에서 사용되는 로직은 어떻게 처리해야 하나? 결국 FE 개발자가 관리하는 백엔드 서버가 해야 한다. 가볍다고는 하지만 결국 풀스택 개발로 돌아가게 된 것이다. 결과적으로 서비스는 결국 아래와 같은 구조가 되어버린다.

 

비즈니스 로직이라는 무거운 짐은 덜었지만 결국 백엔드 개발이 되돌아왔다

 

BFF(데이터 전송용 API 게이트웨이) 서버가 주요 데이터를 처리하는 API를 제공하고, 사용자의 요청을 직접 받는 것은 FE가 관리하는 서버가 된 것이다. FE의 백엔드는 유지보수를 위해 언어의 통일이 필요하고, SSR이나 기타 다양한 기능을 지원하는 Node.js 에코시스템의 프레임워크를 선택할 수 밖다(스케일링과 그에 맞는 성능도 겸해서). 그런 상황에서 어차피 프론트엔드 프레임워크를 사용해야 하니 Next.js나 Nuxt.js 기반 프로젝트를 선택할 수 밖에 없어지는 것이다. 어찌되었건 FE에서 관리할 서버가 준비되었다. 하지만 다음으로 클라이언트에서 직접 호출할 API 엔드포인트 부분이 문제가 된다. FE에서 호출한 요청을 리버스 프록시 처리할 API 엔드포인트를 관리할 방법이 마땅치가 않다. 문서화를 하기에는 양이 많고 변화가 빠르며, 문서화를 하지 않으면 아예 관리가 불가능하다.

이런 상황에서 API를 객체화하여 호출할 수 있도록 만들어주는 라이브러리. 그것이 tRPC이다.

tRPC를 사용할 때의 서버 구성. FE의 요청부터 BE의 컨트롤러까지를 tRPC가 담당하게 된다.

물론 tRPC는 백엔드의 컨트롤러에 해당하는 부분까지 지원하는 것이기 때문에 BFF로 구성되지 않은 일반적인 서비스에도 충분히 활용이 가능하다. 내가 개인적으로 개발하고 있는 프로젝트에서는 tRPC에서 직접 모든 응답을 처리하고 있다. DB에 붙고 데이터를 처리하는 방식은 일반적인 Node.js 백엔드와 같지만 컨트롤러 부분이 tRPC로 대체된 것이다. 이렇듯 tRPC를 이용하면 백엔드 개발과 프론트엔드 개발이 엄청나게 밀접하게 붙게 된다. 이 방식은 이전의 풀스택 개발과 유사하지만, 단순히 같은 언어를 사용하고 같은 프로젝트에서 개발한다는 점을 넘어 같은 코드를 사용하게 되므로 개발자가 몇 없는 프로젝트에서 강력한 생산성을 얻을 수 있다.

 

내가 생각했을 때 tRPC를 쓰면 얻는 장점과 단점은 아래와 같다.

 

장점

  • 강력한 생산성
    • 성능의 하자 없이 아주 빠르게 개발할 수 있다.
  • Next.js / Express.js 등에 쉽게 적용할 수 있도록 한 어댑터 제공
    • 백엔드 프레임워크의 request, response 객체와 연동하여 세션이나 쿠키 사용 가능
  • API 엔드포인트를 클라이언트에서 호출 가능한 객체로 제공
    • 자동 완성 및 타입 추론이 가능해짐
    • 리팩토링 및 유지보수 상황에서 큰 장점
  • zod 및 Typescript 타입(인터페이스) 정의로 GraphQL 스키마 선언에 비해 훨씬 간편하고 능동적
    • Typescript의 타입 유틸 함수나 lodash의 유틸 함수들을 활용해 간편하게 타입 처리가 가능
  • 유행하는 React-Query 기반으로 캐시(cache/stale time)나 페이징 처리 기능 활용 가능
    • 일반적인 상황에서 추가적인 작업이 필요 없음

 

단점

  • 프로젝트에 사용하기 위해 추가적인 설정이 필요
    • API 엔드포인트가 몇 개 없다면 사용하지 않는 것이 더 나을 수 있음
  • 클라이언트가 React-Query 기반이기 때문에 러닝 커브가 있을 수 있음
  • 클라이언트는 내부적으로 fetch API를 사용하기 때문에 타임아웃이 없음
    • 특별한 케이스 외에 필요는 없음
  • 라우터-서브라우터로 구분되는 라우터단(컨트롤러) 관리가 필요 
  • 백엔드로의 요청이 단순한 편이기 때문에 크롤링 및 Rate Limit을 위한 추가 조치가 필요함
    • @upstash/RateLimiter와 같은 것을 이용할 수 있지만 단위를 설정하거나 처리하는데 어려움이 있을 수 있음
  • 아직 메이저라고 보기는 어렵기 때문에 문제가 발생했을 때 파악에 어려움이 있음
  • Rest Doc과 같은 API doc이나 테스팅 툴 적용에 어려움이 있음

 

 

 

3. tRPC의 구성과 예제

Next.js + tRPC 예제 코드: https://github.com/partnerjun/trpc-next-example

이러한 기술들이 모두 그렇듯 tRPC 역시도 추가 설정의 벽을 넘을 수는 없다. 하지만 해도 해도 복잡하고 늘 새로운 Spring 계열 설정과 달리 tRPC의 설정은 아주 간단한 편이다. Github에 올린 이 프로젝트를 본다면 설명 필요 없이 곧바로 이해가 가능할 정도라고 생각한다. 하지만 그냥 넘어가기엔 뭔가 아쉬우니 주요 코드를 설명해본다.

 

이 프로젝트에서 사용한 tRPC의 주요 설정 파일들과 그 역할

tRPC 설정의 시작은 server/context.ts 파일이다. 이 컨텍스트 선언을 통해 각종 기능을 전개해 나간다.

 

tRPC 컨텍스트 선언부 (server/context.ts)

헤더의 userAgent를 뽑아내도록 했다

context.ts 파일은 tRPC 컨텍스트를 선언하는 부분이다. createContext는 객체를 반환하는데, 이는 아래에서 설명할 query/mutation에서의 ctx 객체이다. 세션이나 레디스, 혹은 특정 헤더 값과 같이 요청에서부터 처리해야 하는 값을 이곳에서 등록함으로써 간편하게 개발이 가능해진다. 주의할 점은 서버 사이드에서 호출한 요청은 이 값이 채워지지 않는다는 점이다. 어떻게 보면 당연한 것이, 서버단에서 tRPC 클라이언트를 호출하면 기본적으로는 클라이언트의 Request 객체에 접근이 불가능하기 때문이다(프레임워크별 차이도 있고).

 

 

tRPC 라우터 선언부 (server/routers)

zod를 이용해 name이라는 파라미터를 string/undefined/null 타입으로 선언
위에서 선언한 testRouter를 'test'라는 이름으로 연결

 

tRPC의 라우터는 백엔드에서 흔히 말하는 컨트롤러 부분이며, tRPC를 사용함에 있어 가장 중요한 부분이다. tRPC 10버전부터는 라우터와 서브라우터로 나누어지는 일종의 트리 구조로 개발하도록 가이드되고 있다. 특히 오른쪽 이미지의 publicProcedure로 시작되는 선언부를 눈여겨볼 필요가 있다. 우리가 아는 프로시저라는 이름과 tRPC의 동작 방식에 연관성이 있는지는 의문이지만 이 객체를 이용하여 요청을 받는 컨트롤러 객체를 선언하고, 선언된 객체를 메인 라우터에 연결하면 요청을 처리할 수 있게 된다.

선언부는 크게 요청할때 사용될 타입을 선언하는 input 부분과 query/mutation 부분으로 나누어진다. 먼저 클라이언트에서 요청할 때 사용할 값의 타입을 input 함수 내에 zod로 선언한다. 만약 이 형태에 맞지 않는다면 자동으로 bad request가 응답된다. 그 뒤에 이어서 로직을 처리할 query/mutation을 선언한다. 이렇게 호출되는 query/mutation은 http 요청의 GET/POST에 대응된다. 이 함수 내에서 파라미터로 전달받는 input 객체는 위에서 선언한 타입에 대응되는 데이터를, ctx는 context에서 선언했던 데이터를 받을 수 있다. 이 부분은 앞서 설명한대로 컨트롤러와 아주 유사하기에 더 많은 설명은 필요하지 않을 것이다.

 

Next.js 페이지 파일 (pages/index.ts)

Next.js 페이지 컴포넌트에서 호출하는 tRPC. trpc/sTrpc는 내가 선언한 trpc 클라이언트다.

다음으로는 tRPC 클라이언트로 라우터에 선언해둔 엔드포인트를 호출하는 코드다. 여기서 특이한 점은 브라우저에서 호출하는 클라이언트는 trpc라는 이름으로 되어있고, 백엔드에서(SSR) 호출하는 코드는 sTrpc로 되어있다는 점이다. 이는 환경별로 다른 클라이언트를 사용하기 때문이다.

추가) 이 예제에서는 코드를 간편하게 보여주기 위해 SSG Helper를 선언하지 않았다. 클라이언트 환경엣의 호출과 마찬가지로 context를 전달해야 한다면 SSG Helper를 선언하여 사용하면 된다.

 

trpc 클라이언트 자동완성

trpc 클라이언트는 위와 같이 자동완성이 지원된다. 라우터에서 'test'로 선언한 testRouter, 그리고 testRouter에 선언된 컨트롤러들이 IDE에서 자동완성으로 추천된다. 선언뿐 아니라 이 엔드포인트를 호출할 때 필요한 값도 체크해준다.

 

 

 

4. 결론

앞서 적은 것처럼 tRPC 자체가 혁신적인 기술이라는 생각은 들지 않는다. 먼저 유행을 탔던 GraphQL이 있었고, tRPC를 사용하지 않는다고 해서 이와 유사하게 개발하지 못하는 것도 아니기 때문이다. 특히 React-Query 기반이기에 사용해보지 않았다면 이로 인해 막힐때마다 짜증이 난다. 하지만 이 라이브러리가 제공하는 강력한 생산성을 무시할 수 없다. 앞서 말했듯 BFF를 기반으로 하는 프로젝트 특성상 리버스 프록시 설정이 필요하고, API 엔드포인트를 관리할 방법이 마땅치 않은 지금 상황에서 백엔드와 프론트엔드 양 쪽에서 사용 가능한 방식으로 개발하는 것은 관심을 가질 필요가 있다. 또 급격히 늘어나고 있는 Github Star 수를 볼 때 이 프로젝트는 유행 탈 확률이 높다.

2022년 하반기부터 아주 급격하게 관심을 받고 있다

물론 유행을 쫓는 선택은 가치가 없지만, 개인적으로 JPA - QueryDSL쪽 유행보다는 훨씬 가치있다고 생각된다. 기존에 있던 기술을 더 간편하게 사용할 수 있게 해준다는 점에서는 JPA와 비슷하지만 다른 프레임워크(혹은 라이브러리)와 함께 사용하는 것을 가정하고 만들어졌다는 점, 그리고 성능을 중시하는 대규모 프로젝트를 위한 MFA에서 사용하기 좋다는 점 이 두가지를 무시할 수 없다는 생각이 든다. 어찌되었건 내게 다음 프로젝트에서도 tRPC를 사용할 생각이 있느냐고 묻는다면 난 긍정적인 답을 할 것이다.