Development··6 min read

tRPC로 타입 안전한 API 만든 경험

REST에서 tRPC로 갈아타고 3개월간 느낀 것들, 좋은 점과 뼈아픈 실수 포함

타입 에러가 런타임에서 터지던 날

금요일 오후 4시 37분. 프로덕션에서 500 에러가 터졌다. 원인은 프론트엔드가 보내는 요청 body의 필드명이 userName인데 백엔드는 username을 기대하고 있었다. 대소문자 하나 차이. 이걸 2주 동안 아무도 못 잡았다. (테스트 환경에서는 다른 데이터를 쓰고 있었거든.)

REST API를 쓰면서 이런 일이 한두 번이 아니었다. 프론트랑 백엔드 사이의 타입이 안 맞아서 생기는 버그가 분기당 서너 건. 그래서 tRPC를 도입하기로 했다.

처음 세팅할 때 삽질한 것

tRPC v11 기준으로 세팅했다. Next.js App Router랑 조합하려니까 공식 문서가 좀 부실했다. (정확히는 App Router 관련 부분이 아직 실험적이라는 경고가 달려 있었다.) 세팅에 이틀 반을 썼다. 솔직히 반나절이면 끝날 줄 알았다.

가장 헤맸던 건 서버 사이드에서 tRPC 클라이언트를 쓰는 부분이었다. RSC에서 직접 호출하려면 createCaller를 써야 하는데, 이 패턴이 문서 깊숙이 숨어 있었다. 스택오버플로우 답변도 v10 기준이라 미묘하게 달랐다.

// 이렇게 하면 안 되는데 처음에 이렇게 했다
const data = await trpc.user.getById.query({ id: 1 });
 
// 이렇게 해야 했다
const caller = createCaller(createContext);
const data = await caller.user.getById({ id: 1 });

근데 이게 진짜 편하긴 하다

세팅만 끝나면 개발 속도가 확실히 빨라진다. 백엔드에서 라우터를 정의하면 프론트엔드에서 자동완성이 뜬다. 인풋 타입, 아웃풋 타입 전부. API 명세서를 따로 작성할 필요가 없어졌다.

이전에는 Swagger 문서를 수동으로 관리했는데, 이게 코드랑 싱크가 안 맞는 경우가 많았다. 특히 응답 필드가 추가되거나 nullable로 바뀌었을 때. tRPC에서는 Zod 스키마를 바꾸면 프론트엔드 쪽에서 바로 타입 에러가 뜬다. 컴파일 타임에 잡히니까 런타임 에러가 줄었다.

3개월 동안 타입 불일치 관련 프로덕션 버그가 0건이었다. (이전에는 분기당 3~4건이었으니까 유의미한 차이다.)

여기서부터 꼬이기 시작했다

문제는 tRPC가 REST의 모든 것을 대체할 수 없다는 거다. 외부 서비스 연동이 필요한 웹훅 엔드포인트는 tRPC로 만들 수가 없다. tRPC는 클라이언트-서버 간 통신에 특화된 거라, 외부에서 들어오는 HTTP 요청을 받으려면 결국 REST 엔드포인트가 필요하다.

그래서 지금 프로젝트에는 tRPC 라우터와 REST API Route가 혼재하고 있다. 이게 솔직히 좀 지저분하다. 새로 합류한 팀원이 "이 API는 어디로 요청해야 돼요?"라고 물어볼 때마다 좀 미안하다.

파일 업로드도 까다로웠다. tRPC에서 멀티파트 폼 데이터를 처리하는 게 네이티브하게 지원이 안 된다. 결국 파일 업로드는 별도 REST 엔드포인트로 뺐다. 이런 예외가 하나둘 쌓이면서 "이거 진짜 tRPC 도입이 맞았나" 싶은 순간이 있었다.

그래도 돌아가고 싶지는 않다

3개월 쓰고 나서 결론은, 장점이 단점을 충분히 상쇄한다는 거다. 타입 안전성만으로도 가치가 있다. API 응답 타입이 바뀌었을 때 프론트엔드에서 바로 빨간 줄이 뜨는 경험은 한번 맛보면 돌아가기 어렵다.

근데 모든 프로젝트에 추천하지는 않는다. 외부 API 연동이 많거나 팀에서 REST에 익숙한 사람이 대부분이라면 학습 비용이 꽤 크다. 우리 팀 4명 중 2명은 적응하는 데 한 달 넘게 걸렸다. (사실 한 명은 아직도 가끔 REST로 만들어서 PR을 올린다.)

tRPC 세팅에 이틀 반 쓴 건 아깝지만, 그 이후로 절약한 디버깅 시간이 대략 47시간 정도라 충분히 뽕은 뽑았다.

관련 글